Horis 2 lat temu
rodzic
commit
2e43b17fc8

+ 1 - 0
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"

+ 2 - 0
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)
 

+ 86 - 0
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()
+        }
+    }
+}

+ 2 - 1
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) {

+ 188 - 2
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<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 {
             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(
         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) {

+ 9 - 2
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
                 }

+ 7 - 2
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")

+ 2 - 0
app/src/main/res/values-es-rES/strings.xml

@@ -1159,4 +1159,6 @@
     <string name="official_version">正式版</string>
     <string name="beta_release_version">测试版</string>
     <string name="beta_releaseA_version">共存版</string>
+    <string name="stream_read_aloud_audio">流式播放音频</string>
+    <string name="stream_read_aloud_audio_summary">即边下边播,网络不好时播放会断断续续,仅TTS源有效</string>
 </resources>

+ 2 - 0
app/src/main/res/values-ja-rJP/strings.xml

@@ -1162,4 +1162,6 @@
     <string name="official_version">正式版</string>
     <string name="beta_release_version">测试版</string>
     <string name="beta_releaseA_version">共存版</string>
+    <string name="stream_read_aloud_audio">流式播放音频</string>
+    <string name="stream_read_aloud_audio_summary">即边下边播,网络不好时播放会断断续续,仅TTS源有效</string>
 </resources>

+ 2 - 0
app/src/main/res/values-pt-rBR/strings.xml

@@ -1162,4 +1162,6 @@
     <string name="official_version">正式版</string>
     <string name="beta_release_version">测试版</string>
     <string name="beta_releaseA_version">共存版</string>
+    <string name="stream_read_aloud_audio">流式播放音频</string>
+    <string name="stream_read_aloud_audio_summary">即边下边播,网络不好时播放会断断续续,仅TTS源有效</string>
 </resources>

+ 2 - 0
app/src/main/res/values-vi/strings.xml

@@ -1158,4 +1158,6 @@ Còn </string>
     <string name="official_version">正式版</string>
     <string name="beta_release_version">测试版</string>
     <string name="beta_releaseA_version">共存版</string>
+    <string name="stream_read_aloud_audio">流式播放音频</string>
+    <string name="stream_read_aloud_audio_summary">即边下边播,网络不好时播放会断断续续,仅TTS源有效</string>
 </resources>

+ 2 - 0
app/src/main/res/values-zh-rHK/strings.xml

@@ -1159,4 +1159,6 @@
     <string name="official_version">正式版</string>
     <string name="beta_release_version">测试版</string>
     <string name="beta_releaseA_version">共存版</string>
+    <string name="stream_read_aloud_audio">流式播放音频</string>
+    <string name="stream_read_aloud_audio_summary">即边下边播,网络不好时播放会断断续续,仅TTS源有效</string>
 </resources>

+ 2 - 0
app/src/main/res/values-zh-rTW/strings.xml

@@ -1161,4 +1161,6 @@
     <string name="official_version">正式版</string>
     <string name="beta_release_version">测试版</string>
     <string name="beta_releaseA_version">共存版</string>
+    <string name="stream_read_aloud_audio">流式播放音频</string>
+    <string name="stream_read_aloud_audio_summary">即边下边播,网络不好时播放会断断续续,仅TTS源有效</string>
 </resources>

+ 2 - 0
app/src/main/res/values-zh/strings.xml

@@ -1161,4 +1161,6 @@
     <string name="official_version">正式版</string>
     <string name="beta_release_version">测试版</string>
     <string name="beta_releaseA_version">共存版</string>
+    <string name="stream_read_aloud_audio">流式播放音频</string>
+    <string name="stream_read_aloud_audio_summary">即边下边播,网络不好时播放会断断续续,仅TTS源有效</string>
 </resources>

+ 2 - 0
app/src/main/res/values/strings.xml

@@ -1162,4 +1162,6 @@
     <string name="official_version">正式版</string>
     <string name="beta_release_version">测试版</string>
     <string name="beta_releaseA_version">共存版</string>
+    <string name="stream_read_aloud_audio">流式播放音频</string>
+    <string name="stream_read_aloud_audio_summary">即边下边播,网络不好时播放会断断续续,仅TTS源有效</string>
 </resources>

+ 7 - 0
app/src/main/res/xml/pref_config_aloud.xml

@@ -35,6 +35,13 @@
             android:key="readAloudByPage"
             app:iconSpaceReserved="false" />
 
+        <io.legado.app.lib.prefs.SwitchPreference
+            android:defaultValue="false"
+            android:title="@string/stream_read_aloud_audio"
+            android:summary="@string/stream_read_aloud_audio_summary"
+            android:key="streamReadAloudAudio"
+            app:iconSpaceReserved="false" />
+
         <io.legado.app.lib.prefs.Preference
             android:title="@string/speak_engine"
             android:summary="TTS"