This commit is contained in:
Horis
2024-07-29 10:46:32 +08:00
parent 25df73de56
commit 9172659440
16 changed files with 318 additions and 7 deletions
@@ -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"
@@ -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)
@@ -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()
}
}
}
@@ -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) {
@@ -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) {
@@ -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
}
@@ -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")
@@ -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>
@@ -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>
@@ -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
View File
@@ -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>
@@ -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>
@@ -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
View File
@@ -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
View File
@@ -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>
@@ -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"