diff --git a/app/src/main/java/io/legado/app/constant/PreferKey.kt b/app/src/main/java/io/legado/app/constant/PreferKey.kt index 5c5ec94d6..050f020a4 100644 --- a/app/src/main/java/io/legado/app/constant/PreferKey.kt +++ b/app/src/main/java/io/legado/app/constant/PreferKey.kt @@ -146,6 +146,7 @@ object PreferKey { const val recordHeapDump = "recordHeapDump" const val optimizeRender = "optimizeRender" const val updateToVariant = "updateToVariant" + const val streamReadAloudAudio = "streamReadAloudAudio" const val cPrimary = "colorPrimary" const val cAccent = "colorAccent" diff --git a/app/src/main/java/io/legado/app/help/config/AppConfig.kt b/app/src/main/java/io/legado/app/help/config/AppConfig.kt index ed3a3f43f..ff9028e30 100644 --- a/app/src/main/java/io/legado/app/help/config/AppConfig.kt +++ b/app/src/main/java/io/legado/app/help/config/AppConfig.kt @@ -473,6 +473,8 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener { val updateToVariant get() = appCtx.getPrefString(PreferKey.updateToVariant, "default_version") + val streamReadAloudAudio get() = appCtx.getPrefBoolean(PreferKey.streamReadAloudAudio, false) + val doublePageHorizontal: String? get() = appCtx.getPrefString(PreferKey.doublePageHorizontal) diff --git a/app/src/main/java/io/legado/app/help/exoplayer/InputStreamDataSource.kt b/app/src/main/java/io/legado/app/help/exoplayer/InputStreamDataSource.kt new file mode 100644 index 000000000..e77fd10c8 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/exoplayer/InputStreamDataSource.kt @@ -0,0 +1,86 @@ +package io.legado.app.help.exoplayer + +import android.annotation.SuppressLint +import android.net.Uri +import androidx.media3.common.C +import androidx.media3.datasource.BaseDataSource +import androidx.media3.datasource.DataSpec +import java.io.EOFException +import java.io.IOException +import java.io.InputStream +import kotlin.math.min + + +@SuppressLint("UnsafeOptInUsageError") +class InputStreamDataSource(private val supplier: () -> InputStream) : BaseDataSource(false) { + private var dataSpec: DataSpec? = null + private var bytesRemaining: Long = 0 + private var opened = false + private val inputStream by lazy { + supplier.invoke() + } + + @Throws(IOException::class) + override fun open(dataSpec: DataSpec): Long { + this.dataSpec = dataSpec + transferInitializing(dataSpec) + + inputStream.skip(dataSpec.position) + + if (dataSpec.length == C.LENGTH_UNSET.toLong()) { + bytesRemaining = inputStream.available().toLong() + if (bytesRemaining == 0L) bytesRemaining = C.LENGTH_UNSET.toLong() + } else { + bytesRemaining = dataSpec.length + } + + opened = true + transferStarted(dataSpec) + return bytesRemaining + } + + override fun getUri(): Uri? = dataSpec?.uri + + @Throws(IOException::class) + override fun read(buffer: ByteArray, offset: Int, readLength: Int): Int { + if (readLength == 0) { + return 0 + } else if (bytesRemaining == 0L) { + return C.RESULT_END_OF_INPUT + } + + val bytesToRead = + if (bytesRemaining == C.LENGTH_UNSET.toLong()) readLength + else min(bytesRemaining, readLength.toLong()).toInt() + + val bytesRead = inputStream.read(buffer, offset, bytesToRead) + + if (bytesRead == -1) { + if (bytesRemaining != C.LENGTH_UNSET.toLong()) { + // End of stream reached having not read sufficient data. + throw EOFException() + } + return C.RESULT_END_OF_INPUT + } + + if (bytesRemaining != C.LENGTH_UNSET.toLong()) { + bytesRemaining -= bytesRead.toLong() + bytesTransferred(bytesRead) + } + + return bytesRead + } + + @Throws(IOException::class) + override fun close() { + if (!opened) { + return + } + try { + inputStream.close() + } finally { + opened = false + transferEnded() + } + } +} diff --git a/app/src/main/java/io/legado/app/service/BaseReadAloudService.kt b/app/src/main/java/io/legado/app/service/BaseReadAloudService.kt index 03263896a..00655a277 100644 --- a/app/src/main/java/io/legado/app/service/BaseReadAloudService.kt +++ b/app/src/main/java/io/legado/app/service/BaseReadAloudService.kt @@ -113,7 +113,8 @@ abstract class BaseReadAloudService : BaseService(), var pageChanged = false private var toLast = false var paragraphStartPos = 0 - private var readAloudByPage = false + var readAloudByPage = false + private set private val broadcastReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { diff --git a/app/src/main/java/io/legado/app/service/HttpReadAloudService.kt b/app/src/main/java/io/legado/app/service/HttpReadAloudService.kt index fe300c5c3..3bfa6fcc6 100644 --- a/app/src/main/java/io/legado/app/service/HttpReadAloudService.kt +++ b/app/src/main/java/io/legado/app/service/HttpReadAloudService.kt @@ -1,12 +1,27 @@ package io.legado.app.service +import android.annotation.SuppressLint import android.app.PendingIntent import android.net.Uri 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.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 @@ -15,6 +30,8 @@ import io.legado.app.data.entities.HttpTTS import io.legado.app.exception.NoStackTraceException 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 @@ -26,13 +43,16 @@ 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.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 @@ -42,6 +62,7 @@ import kotlin.coroutines.coroutineContext /** * 在线朗读 */ +@SuppressLint("UnsafeOptInUsageError") class HttpReadAloudService : BaseReadAloudService(), Player.Listener { @@ -51,6 +72,24 @@ class HttpReadAloudService : BaseReadAloudService(), private val ttsFolderPath: String by lazy { 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 downloadTask: Coroutine<*>? = null private var playIndexJob: Job? = null @@ -67,6 +106,7 @@ class HttpReadAloudService : BaseReadAloudService(), super.onDestroy() downloadTask?.cancel() exoPlayer.release() + cache.release() Coroutine.async { removeCacheFile() } @@ -81,7 +121,11 @@ class HttpReadAloudService : BaseReadAloudService(), ReadBook.readAloud() } else { 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() + 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 { + AppLog.put("朗读下载出错\n${it.localizedMessage}", it, true) + } + } + + private suspend fun preDownloadAudiosStream( + httpTts: HttpTTS, + downloaderChannel: Channel + ) { + 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( httpTts: HttpTTS, speakText: String @@ -329,7 +511,11 @@ class HttpReadAloudService : BaseReadAloudService(), downloadTask?.cancel() exoPlayer.stop() speechRate = AppConfig.speechRatePlay + 5 - downloadAndPlayAudios() + if (AppConfig.streamReadAloudAudio) { + downloadAndPlayAudiosStream() + } else { + downloadAndPlayAudios() + } } override fun onPlaybackStateChanged(playbackState: Int) { diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/ReadAloudConfigDialog.kt b/app/src/main/java/io/legado/app/ui/book/read/config/ReadAloudConfigDialog.kt index e303a6d7e..86f2fa5c2 100644 --- a/app/src/main/java/io/legado/app/ui/book/read/config/ReadAloudConfigDialog.kt +++ b/app/src/main/java/io/legado/app/ui/book/read/config/ReadAloudConfigDialog.kt @@ -20,7 +20,13 @@ import io.legado.app.lib.theme.backgroundColor import io.legado.app.lib.theme.primaryColor import io.legado.app.model.ReadAloud import io.legado.app.service.BaseReadAloudService -import io.legado.app.utils.* +import io.legado.app.utils.GSON +import io.legado.app.utils.StringUtils +import io.legado.app.utils.fromJsonObject +import io.legado.app.utils.postEvent +import io.legado.app.utils.setEdgeEffectColor +import io.legado.app.utils.setLayout +import io.legado.app.utils.showDialogFragment class ReadAloudConfigDialog : DialogFragment() { private val readAloudPreferTag = "readAloudPreferTag" @@ -103,7 +109,7 @@ class ReadAloudConfigDialog : DialogFragment() { key: String? ) { when (key) { - PreferKey.readAloudByPage -> { + PreferKey.readAloudByPage, PreferKey.streamReadAloudAudio -> { if (BaseReadAloudService.isRun) { postEvent(EventBus.MEDIA_BUTTON, false) } @@ -117,6 +123,7 @@ class ReadAloudConfigDialog : DialogFragment() { val index = preference.findIndexOfValue(value) preference.summary = if (index >= 0) preference.entries[index] else null } + else -> { preference?.summary = value } diff --git a/app/src/main/java/io/legado/app/ui/book/read/page/entities/TextChapter.kt b/app/src/main/java/io/legado/app/ui/book/read/page/entities/TextChapter.kt index dd6fb476a..b48c37850 100644 --- a/app/src/main/java/io/legado/app/ui/book/read/page/entities/TextChapter.kt +++ b/app/src/main/java/io/legado/app/ui/book/read/page/entities/TextChapter.kt @@ -177,10 +177,15 @@ data class TextChapter( * @param pageSplit 是否分页 * @param startPos 从当前页什么地方开始朗读 */ - fun getNeedReadAloud(pageIndex: Int, pageSplit: Boolean, startPos: Int): String { + fun getNeedReadAloud( + pageIndex: Int, + pageSplit: Boolean, + startPos: Int, + pageEndIndex: Int = pages.lastIndex + ): String { val stringBuilder = StringBuilder() if (pages.isNotEmpty()) { - for (index in pageIndex..pages.lastIndex) { + for (index in pageIndex..min(pageEndIndex, pages.lastIndex)) { stringBuilder.append(pages[index].text) if (pageSplit && !stringBuilder.endsWith("\n")) { stringBuilder.append("\n") diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 2b81312ed..8df0f35bf 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -1159,4 +1159,6 @@ 正式版 测试版 共存版 + 流式播放音频 + 即边下边播,网络不好时播放会断断续续,仅TTS源有效 diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index 8a4d6f682..c84baa562 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -1162,4 +1162,6 @@ 正式版 测试版 共存版 + 流式播放音频 + 即边下边播,网络不好时播放会断断续续,仅TTS源有效 diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index aae0273c5..07acc34d1 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1162,4 +1162,6 @@ 正式版 测试版 共存版 + 流式播放音频 + 即边下边播,网络不好时播放会断断续续,仅TTS源有效 diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 756aba31a..82813ca71 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -1158,4 +1158,6 @@ Còn 正式版 测试版 共存版 + 流式播放音频 + 即边下边播,网络不好时播放会断断续续,仅TTS源有效 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index 13adece3f..7649b6315 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -1159,4 +1159,6 @@ 正式版 测试版 共存版 + 流式播放音频 + 即边下边播,网络不好时播放会断断续续,仅TTS源有效 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 029498d86..b0d942e2c 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1161,4 +1161,6 @@ 正式版 测试版 共存版 + 流式播放音频 + 即边下边播,网络不好时播放会断断续续,仅TTS源有效 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index c44f90dc1..e72e677de 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -1161,4 +1161,6 @@ 正式版 测试版 共存版 + 流式播放音频 + 即边下边播,网络不好时播放会断断续续,仅TTS源有效 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2b890e34b..2e4e8b22e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1162,4 +1162,6 @@ 正式版 测试版 共存版 + 流式播放音频 + 即边下边播,网络不好时播放会断断续续,仅TTS源有效 diff --git a/app/src/main/res/xml/pref_config_aloud.xml b/app/src/main/res/xml/pref_config_aloud.xml index 21e02f5ed..9813aba85 100644 --- a/app/src/main/res/xml/pref_config_aloud.xml +++ b/app/src/main/res/xml/pref_config_aloud.xml @@ -35,6 +35,13 @@ android:key="readAloudByPage" app:iconSpaceReserved="false" /> + +