|
@@ -1,12 +1,27 @@
|
|
|
package io.legado.app.service
|
|
package io.legado.app.service
|
|
|
|
|
|
|
|
|
|
+import android.annotation.SuppressLint
|
|
|
import android.app.PendingIntent
|
|
import android.app.PendingIntent
|
|
|
import android.net.Uri
|
|
import android.net.Uri
|
|
|
import androidx.lifecycle.lifecycleScope
|
|
import androidx.lifecycle.lifecycleScope
|
|
|
|
|
+import androidx.media3.common.C
|
|
|
import androidx.media3.common.MediaItem
|
|
import androidx.media3.common.MediaItem
|
|
|
import androidx.media3.common.PlaybackException
|
|
import androidx.media3.common.PlaybackException
|
|
|
import androidx.media3.common.Player
|
|
import androidx.media3.common.Player
|
|
|
|
|
+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.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 com.script.ScriptException
|
|
|
import io.legado.app.R
|
|
import io.legado.app.R
|
|
|
import io.legado.app.constant.AppLog
|
|
import io.legado.app.constant.AppLog
|
|
@@ -15,6 +30,8 @@ import io.legado.app.data.entities.HttpTTS
|
|
|
import io.legado.app.exception.NoStackTraceException
|
|
import io.legado.app.exception.NoStackTraceException
|
|
|
import io.legado.app.help.config.AppConfig
|
|
import io.legado.app.help.config.AppConfig
|
|
|
import io.legado.app.help.coroutine.Coroutine
|
|
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.ReadAloud
|
|
|
import io.legado.app.model.ReadBook
|
|
import io.legado.app.model.ReadBook
|
|
|
import io.legado.app.model.analyzeRule.AnalyzeUrl
|
|
import io.legado.app.model.analyzeRule.AnalyzeUrl
|
|
@@ -26,13 +43,16 @@ import io.legado.app.utils.toastOnUi
|
|
|
import kotlinx.coroutines.CancellationException
|
|
import kotlinx.coroutines.CancellationException
|
|
|
import kotlinx.coroutines.Dispatchers.Main
|
|
import kotlinx.coroutines.Dispatchers.Main
|
|
|
import kotlinx.coroutines.Job
|
|
import kotlinx.coroutines.Job
|
|
|
|
|
+import kotlinx.coroutines.channels.Channel
|
|
|
import kotlinx.coroutines.delay
|
|
import kotlinx.coroutines.delay
|
|
|
import kotlinx.coroutines.ensureActive
|
|
import kotlinx.coroutines.ensureActive
|
|
|
import kotlinx.coroutines.launch
|
|
import kotlinx.coroutines.launch
|
|
|
|
|
+import kotlinx.coroutines.runBlocking
|
|
|
import kotlinx.coroutines.sync.Mutex
|
|
import kotlinx.coroutines.sync.Mutex
|
|
|
import kotlinx.coroutines.sync.withLock
|
|
import kotlinx.coroutines.sync.withLock
|
|
|
import okhttp3.Response
|
|
import okhttp3.Response
|
|
|
import org.mozilla.javascript.WrappedException
|
|
import org.mozilla.javascript.WrappedException
|
|
|
|
|
+import splitties.init.appCtx
|
|
|
import java.io.File
|
|
import java.io.File
|
|
|
import java.io.InputStream
|
|
import java.io.InputStream
|
|
|
import java.net.ConnectException
|
|
import java.net.ConnectException
|
|
@@ -42,6 +62,7 @@ import kotlin.coroutines.coroutineContext
|
|
|
/**
|
|
/**
|
|
|
* 在线朗读
|
|
* 在线朗读
|
|
|
*/
|
|
*/
|
|
|
|
|
+@SuppressLint("UnsafeOptInUsageError")
|
|
|
class HttpReadAloudService : BaseReadAloudService(),
|
|
class HttpReadAloudService : BaseReadAloudService(),
|
|
|
Player.Listener {
|
|
Player.Listener {
|
|
|
|
|
|
|
@@ -51,6 +72,24 @@ class HttpReadAloudService : BaseReadAloudService(),
|
|
|
private val ttsFolderPath: String by lazy {
|
|
private val ttsFolderPath: String by lazy {
|
|
|
cacheDir.absolutePath + File.separator + "httpTTS" + File.separator
|
|
cacheDir.absolutePath + File.separator + "httpTTS" + File.separator
|
|
|
}
|
|
}
|
|
|
|
|
+ private val cache by lazy {
|
|
|
|
|
+ SimpleCache(
|
|
|
|
|
+ File(cacheDir, "httpTTS_cache"),
|
|
|
|
|
+ LeastRecentlyUsedCacheEvictor((50 * 1024 * 1024).toLong()),
|
|
|
|
|
+ StandaloneDatabaseProvider(appCtx)
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ private val cacheDataSinkFactory by lazy {
|
|
|
|
|
+ CacheDataSink.Factory()
|
|
|
|
|
+ .setCache(cache)
|
|
|
|
|
+ }
|
|
|
|
|
+ private val loadErrorHandlingPolicy by lazy {
|
|
|
|
|
+ object : DefaultLoadErrorHandlingPolicy(0) {
|
|
|
|
|
+ override fun getRetryDelayMsFor(loadErrorInfo: LoadErrorHandlingPolicy.LoadErrorInfo): Long {
|
|
|
|
|
+ return C.TIME_UNSET
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
private var speechRate: Int = AppConfig.speechRatePlay + 5
|
|
private var speechRate: Int = AppConfig.speechRatePlay + 5
|
|
|
private var downloadTask: Coroutine<*>? = null
|
|
private var downloadTask: Coroutine<*>? = null
|
|
|
private var playIndexJob: Job? = null
|
|
private var playIndexJob: Job? = null
|
|
@@ -67,6 +106,7 @@ class HttpReadAloudService : BaseReadAloudService(),
|
|
|
super.onDestroy()
|
|
super.onDestroy()
|
|
|
downloadTask?.cancel()
|
|
downloadTask?.cancel()
|
|
|
exoPlayer.release()
|
|
exoPlayer.release()
|
|
|
|
|
+ cache.release()
|
|
|
Coroutine.async {
|
|
Coroutine.async {
|
|
|
removeCacheFile()
|
|
removeCacheFile()
|
|
|
}
|
|
}
|
|
@@ -81,7 +121,11 @@ class HttpReadAloudService : BaseReadAloudService(),
|
|
|
ReadBook.readAloud()
|
|
ReadBook.readAloud()
|
|
|
} else {
|
|
} else {
|
|
|
super.play()
|
|
super.play()
|
|
|
- downloadAndPlayAudios()
|
|
|
|
|
|
|
+ if (AppConfig.streamReadAloudAudio) {
|
|
|
|
|
+ downloadAndPlayAudiosStream()
|
|
|
|
|
+ } else {
|
|
|
|
|
+ downloadAndPlayAudios()
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -149,12 +193,150 @@ class HttpReadAloudService : BaseReadAloudService(),
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
+ preDownloadAudios(httpTts)
|
|
|
|
|
+ }
|
|
|
|
|
+ }.onError {
|
|
|
|
|
+ AppLog.put("朗读下载出错\n${it.localizedMessage}", it, true)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private suspend fun preDownloadAudios(httpTts: HttpTTS) {
|
|
|
|
|
+ val textChapter = ReadBook.nextTextChapter ?: return
|
|
|
|
|
+ val contentList = textChapter.getNeedReadAloud(0, readAloudByPage, 0, 1)
|
|
|
|
|
+ .splitToSequence("\n")
|
|
|
|
|
+ .filter { it.isNotEmpty() }
|
|
|
|
|
+ .take(10)
|
|
|
|
|
+ .toList()
|
|
|
|
|
+ contentList.forEach { content ->
|
|
|
|
|
+ coroutineContext.ensureActive()
|
|
|
|
|
+ val fileName = md5SpeakFileName(content)
|
|
|
|
|
+ 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) {
|
|
|
|
|
+ if (exoPlayer.playbackState == Player.STATE_ENDED) {
|
|
|
|
|
+ exoPlayer.stop()
|
|
|
|
|
+ exoPlayer.clearMediaItems()
|
|
|
|
|
+ }
|
|
|
|
|
+ exoPlayer.addMediaSource(mediaSource)
|
|
|
|
|
+ if (!exoPlayer.isPlaying) {
|
|
|
|
|
+ exoPlayer.playWhenReady = !pause
|
|
|
|
|
+ exoPlayer.prepare()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ preDownloadAudiosStream(httpTts, downloaderChannel)
|
|
|
}
|
|
}
|
|
|
}.onError {
|
|
}.onError {
|
|
|
AppLog.put("朗读下载出错\n${it.localizedMessage}", it, true)
|
|
AppLog.put("朗读下载出错\n${it.localizedMessage}", it, true)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ private suspend fun preDownloadAudiosStream(
|
|
|
|
|
+ httpTts: HttpTTS,
|
|
|
|
|
+ downloaderChannel: Channel<Downloader>
|
|
|
|
|
+ ) {
|
|
|
|
|
+ val textChapter = ReadBook.nextTextChapter ?: return
|
|
|
|
|
+ val contentList = textChapter.getNeedReadAloud(0, readAloudByPage, 0, 1)
|
|
|
|
|
+ .splitToSequence("\n")
|
|
|
|
|
+ .filter { it.isNotEmpty() }
|
|
|
|
|
+ .take(10)
|
|
|
|
|
+ .toList()
|
|
|
|
|
+ contentList.forEach { content ->
|
|
|
|
|
+ coroutineContext.ensureActive()
|
|
|
|
|
+ val fileName = md5SpeakFileName(content)
|
|
|
|
|
+ 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 {
|
|
|
|
|
+ getSpeakStream(httpTts, speakText)
|
|
|
|
|
+ }
|
|
|
|
|
+ }.onFailure {
|
|
|
|
|
+ when (it) {
|
|
|
|
|
+ is InterruptedException -> 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 = Uri.parse(fileName)
|
|
|
|
|
+ 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(
|
|
private suspend fun getSpeakStream(
|
|
|
httpTts: HttpTTS,
|
|
httpTts: HttpTTS,
|
|
|
speakText: String
|
|
speakText: String
|
|
@@ -329,7 +511,11 @@ class HttpReadAloudService : BaseReadAloudService(),
|
|
|
downloadTask?.cancel()
|
|
downloadTask?.cancel()
|
|
|
exoPlayer.stop()
|
|
exoPlayer.stop()
|
|
|
speechRate = AppConfig.speechRatePlay + 5
|
|
speechRate = AppConfig.speechRatePlay + 5
|
|
|
- downloadAndPlayAudios()
|
|
|
|
|
|
|
+ if (AppConfig.streamReadAloudAudio) {
|
|
|
|
|
+ downloadAndPlayAudiosStream()
|
|
|
|
|
+ } else {
|
|
|
|
|
+ downloadAndPlayAudios()
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
|
override fun onPlaybackStateChanged(playbackState: Int) {
|