Horis 4 лет назад
Родитель
Сommit
efba94fb1a
27 измененных файлов с 318 добавлено и 236 удалено
  1. 1 0
      app/build.gradle
  2. 4 3
      app/src/main/java/io/legado/app/base/BaseService.kt
  3. 1 0
      app/src/main/java/io/legado/app/constant/EventBus.kt
  4. 2 0
      app/src/main/java/io/legado/app/constant/PreferKey.kt
  5. 6 1
      app/src/main/java/io/legado/app/help/AppWebDav.kt
  6. 6 0
      app/src/main/java/io/legado/app/help/config/AppConfig.kt
  7. 1 0
      app/src/main/java/io/legado/app/help/coroutine/Coroutine.kt
  8. 16 0
      app/src/main/java/io/legado/app/model/ReadAloud.kt
  9. 1 1
      app/src/main/java/io/legado/app/model/ReadBook.kt
  10. 92 124
      app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeUrl.kt
  11. 11 0
      app/src/main/java/io/legado/app/service/BaseReadAloudService.kt
  12. 104 103
      app/src/main/java/io/legado/app/service/HttpReadAloudService.kt
  13. 3 0
      app/src/main/java/io/legado/app/ui/book/read/config/MoreConfigDialog.kt
  14. 1 2
      app/src/main/java/io/legado/app/ui/book/read/config/ReadAloudDialog.kt
  15. 1 0
      app/src/main/java/io/legado/app/ui/book/read/page/ReadView.kt
  16. 10 0
      app/src/main/java/io/legado/app/ui/book/read/page/delegate/ScrollPageDelegate.kt
  17. 5 2
      app/src/main/java/io/legado/app/ui/config/BackupConfigFragment.kt
  18. 25 0
      app/src/main/java/io/legado/app/utils/EventBusExtensions.kt
  19. 2 0
      app/src/main/res/values-es-rES/strings.xml
  20. 2 0
      app/src/main/res/values-ja-rJP/strings.xml
  21. 2 0
      app/src/main/res/values-pt-rBR/strings.xml
  22. 2 0
      app/src/main/res/values-zh-rHK/strings.xml
  23. 2 0
      app/src/main/res/values-zh-rTW/strings.xml
  24. 2 0
      app/src/main/res/values-zh/strings.xml
  25. 2 0
      app/src/main/res/values/strings.xml
  26. 7 0
      app/src/main/res/xml/pref_config_backup.xml
  27. 7 0
      app/src/main/res/xml/pref_config_read.xml

+ 1 - 0
app/build.gradle

@@ -170,6 +170,7 @@ dependencies {
     //lifecycle
     def lifecycle_version = '2.5.1'
     implementation("androidx.lifecycle:lifecycle-common-java8:$lifecycle_version")
+    implementation("androidx.lifecycle:lifecycle-service:$lifecycle_version")
 
     //compose
     // Integration with activities

+ 4 - 3
app/src/main/java/io/legado/app/base/BaseService.kt

@@ -1,15 +1,15 @@
 package io.legado.app.base
 
-import android.app.Service
 import android.content.Intent
 import android.os.IBinder
 import androidx.annotation.CallSuper
+import androidx.lifecycle.LifecycleService
 import io.legado.app.help.LifecycleHelp
 import io.legado.app.help.coroutine.Coroutine
 import kotlinx.coroutines.*
 import kotlin.coroutines.CoroutineContext
 
-abstract class BaseService : Service(), CoroutineScope by MainScope() {
+abstract class BaseService : LifecycleService(), CoroutineScope by MainScope() {
 
     fun <T> execute(
         scope: CoroutineScope = this,
@@ -30,7 +30,8 @@ abstract class BaseService : Service(), CoroutineScope by MainScope() {
         stopSelf()
     }
 
-    override fun onBind(intent: Intent?): IBinder? {
+    override fun onBind(intent: Intent): IBinder? {
+        super.onBind(intent)
         return null
     }
 

+ 1 - 0
app/src/main/java/io/legado/app/constant/EventBus.kt

@@ -30,4 +30,5 @@ object EventBus {
     const val FILE_SOURCE_DOWNLOAD_DONE = "fileSourceDownloadDone"
     const val UPDATE_READ_ACTION_BAR = "updateReadActionBar"
     const val UP_SEEK_BAR = "upSeekBar"
+    const val READ_ALOUD_PLAY = "readAloudPlay"
 }

+ 2 - 0
app/src/main/java/io/legado/app/constant/PreferKey.kt

@@ -117,6 +117,8 @@ object PreferKey {
     const val progressBarBehavior = "progressBarBehavior"
     const val sourceEditMaxLine = "sourceEditMaxLine"
     const val ttsTimer = "ttsTimer"
+    const val noAnimScrollPage = "noAnimScrollPage"
+    const val webDavDeviceName = "webDavDeviceName"
 
     const val cPrimary = "colorPrimary"
     const val cAccent = "colorAccent"

+ 6 - 1
app/src/main/java/io/legado/app/help/AppWebDav.kt

@@ -68,7 +68,12 @@ object AppWebDav {
         get() {
             val backupDate = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
                 .format(Date(System.currentTimeMillis()))
-            return "backup${backupDate}.zip"
+            val deviceName = AppConfig.webDavDeviceName
+            return if (deviceName?.isNotBlank() == true) {
+                "backup${backupDate}-${deviceName}.zip"
+            } else {
+                "backup${backupDate}.zip"
+            }
         }
 
     suspend fun upConfig() {

+ 6 - 0
app/src/main/java/io/legado/app/help/config/AppConfig.kt

@@ -2,6 +2,7 @@ package io.legado.app.help.config
 
 import android.content.Context
 import android.content.SharedPreferences
+import android.os.Build
 import io.legado.app.BuildConfig
 import io.legado.app.constant.AppConst
 import io.legado.app.constant.PreferKey
@@ -184,6 +185,9 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
             appCtx.putPrefBoolean(PreferKey.ttsFollowSys, value)
         }
 
+    val noAnimScrollPage: Boolean
+        get() = appCtx.getPrefBoolean(PreferKey.noAnimScrollPage, false)
+
     const val defaultSpeechRate = 5
 
     var ttsSpeechRate: Int
@@ -334,6 +338,8 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
 
     val webDavDir get() = appCtx.getPrefString(PreferKey.webDavDir, "legado")
 
+    val webDavDeviceName get() = appCtx.getPrefString(PreferKey.webDavDeviceName, Build.MODEL)
+
     val recordLog get() = appCtx.getPrefBoolean(PreferKey.recordLog)
 
     val loadCoverOnlyWifi get() = appCtx.getPrefBoolean(PreferKey.loadCoverOnlyWifi, false)

+ 1 - 0
app/src/main/java/io/legado/app/help/coroutine/Coroutine.kt

@@ -99,6 +99,7 @@ class Coroutine<T>(
         return this@Coroutine
     }
 
+    // 如果协程被取消,有可能会不执行
     fun onFinally(
         context: CoroutineContext? = null,
         block: suspend CoroutineScope.() -> Unit

+ 16 - 0
app/src/main/java/io/legado/app/model/ReadAloud.kt

@@ -2,6 +2,8 @@ package io.legado.app.model
 
 import android.content.Context
 import android.content.Intent
+import android.os.Bundle
+import io.legado.app.constant.EventBus
 import io.legado.app.constant.IntentAction
 import io.legado.app.data.appDb
 import io.legado.app.data.entities.HttpTTS
@@ -10,6 +12,7 @@ import io.legado.app.service.BaseReadAloudService
 import io.legado.app.service.HttpReadAloudService
 import io.legado.app.service.TTSReadAloudService
 import io.legado.app.utils.StringUtils
+import io.legado.app.utils.postEvent
 import splitties.init.appCtx
 
 object ReadAloud {
@@ -50,6 +53,19 @@ object ReadAloud {
         context.startService(intent)
     }
 
+    fun playByEventBus(
+        play: Boolean = true,
+        pageIndex: Int = ReadBook.durPageIndex,
+        startPos: Int = 0
+    ) {
+        val bundle = Bundle().apply {
+            putBoolean("play", play)
+            putInt("pageIndex", pageIndex)
+            putInt("startPos", startPos)
+        }
+        postEvent(EventBus.READ_ALOUD_PLAY, bundle)
+    }
+
     fun pause(context: Context) {
         if (BaseReadAloudService.isRun) {
             val intent = Intent(context, aloudClass)

+ 1 - 1
app/src/main/java/io/legado/app/model/ReadBook.kt

@@ -234,7 +234,7 @@ object ReadBook : CoroutineScope by MainScope() {
     private fun curPageChanged() {
         callBack?.pageChanged()
         if (BaseReadAloudService.isRun) {
-            readAloud(!BaseReadAloudService.pause)
+            ReadAloud.playByEventBus(!BaseReadAloudService.pause)
         }
         upReadTime()
         preDownload()

+ 92 - 124
app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeUrl.kt

@@ -354,60 +354,63 @@ class AnalyzeUrl(
             return StrResponse(url, HexUtil.encodeHexStr(getByteArrayAwait()))
         }
         val concurrentRecord = fetchStart()
-        setCookie(source?.getKey())
-        val strResponse: StrResponse
-        if (this.useWebView && useWebView) {
-            strResponse = when (method) {
-                RequestMethod.POST -> {
-                    val res = getProxyClient(proxy).newCallStrResponse(retry) {
-                        addHeaders(headerMap)
-                        url(urlNoQuery)
-                        if (fieldMap.isNotEmpty() || body.isNullOrBlank()) {
-                            postForm(fieldMap, true)
-                        } else {
-                            postJson(body)
+        try {
+            setCookie(source?.getKey())
+            val strResponse: StrResponse
+            if (this.useWebView && useWebView) {
+                strResponse = when (method) {
+                    RequestMethod.POST -> {
+                        val res = getProxyClient(proxy).newCallStrResponse(retry) {
+                            addHeaders(headerMap)
+                            url(urlNoQuery)
+                            if (fieldMap.isNotEmpty() || body.isNullOrBlank()) {
+                                postForm(fieldMap, true)
+                            } else {
+                                postJson(body)
+                            }
                         }
+                        BackstageWebView(
+                            url = res.url,
+                            html = res.body,
+                            tag = source?.getKey(),
+                            javaScript = webJs ?: jsStr,
+                            sourceRegex = sourceRegex,
+                            headerMap = headerMap
+                        ).getStrResponse()
                     }
-                    BackstageWebView(
-                        url = res.url,
-                        html = res.body,
+                    else -> BackstageWebView(
+                        url = url,
                         tag = source?.getKey(),
                         javaScript = webJs ?: jsStr,
                         sourceRegex = sourceRegex,
                         headerMap = headerMap
                     ).getStrResponse()
                 }
-                else -> BackstageWebView(
-                    url = url,
-                    tag = source?.getKey(),
-                    javaScript = webJs ?: jsStr,
-                    sourceRegex = sourceRegex,
-                    headerMap = headerMap
-                ).getStrResponse()
-            }
-        } else {
-            strResponse = getProxyClient(proxy).newCallStrResponse(retry) {
-                addHeaders(headerMap)
-                when (method) {
-                    RequestMethod.POST -> {
-                        url(urlNoQuery)
-                        val contentType = headerMap["Content-Type"]
-                        val body = body
-                        if (fieldMap.isNotEmpty() || body.isNullOrBlank()) {
-                            postForm(fieldMap, true)
-                        } else if (!contentType.isNullOrBlank()) {
-                            val requestBody = body.toRequestBody(contentType.toMediaType())
-                            post(requestBody)
-                        } else {
-                            postJson(body)
+            } else {
+                strResponse = getProxyClient(proxy).newCallStrResponse(retry) {
+                    addHeaders(headerMap)
+                    when (method) {
+                        RequestMethod.POST -> {
+                            url(urlNoQuery)
+                            val contentType = headerMap["Content-Type"]
+                            val body = body
+                            if (fieldMap.isNotEmpty() || body.isNullOrBlank()) {
+                                postForm(fieldMap, true)
+                            } else if (!contentType.isNullOrBlank()) {
+                                val requestBody = body.toRequestBody(contentType.toMediaType())
+                                post(requestBody)
+                            } else {
+                                postJson(body)
+                            }
                         }
+                        else -> get(urlNoQuery, fieldMap, true)
                     }
-                    else -> get(urlNoQuery, fieldMap, true)
                 }
             }
+            return strResponse
+        } finally {
+            fetchEnd(concurrentRecord)
         }
-        fetchEnd(concurrentRecord)
-        return strResponse
     }
 
     @JvmOverloads
@@ -426,54 +429,10 @@ class AnalyzeUrl(
      */
     suspend fun getResponseAwait(): Response {
         val concurrentRecord = fetchStart()
-        setCookie(source?.getKey())
-        @Suppress("BlockingMethodInNonBlockingContext")
-        val response = getProxyClient(proxy).newCallResponse(retry) {
-            addHeaders(headerMap)
-            when (method) {
-                RequestMethod.POST -> {
-                    url(urlNoQuery)
-                    val contentType = headerMap["Content-Type"]
-                    val body = body
-                    if (fieldMap.isNotEmpty() || body.isNullOrBlank()) {
-                        postForm(fieldMap, true)
-                    } else if (!contentType.isNullOrBlank()) {
-                        val requestBody = body.toRequestBody(contentType.toMediaType())
-                        post(requestBody)
-                    } else {
-                        postJson(body)
-                    }
-                }
-                else -> get(urlNoQuery, fieldMap, true)
-            }
-        }
-        fetchEnd(concurrentRecord)
-        return response
-    }
-
-    fun getResponse(): Response {
-        return runBlocking {
-            getResponseAwait()
-        }
-    }
-
-    /**
-     * 访问网站,返回ByteArray
-     */
-    suspend fun getByteArrayAwait(): ByteArray {
-        val concurrentRecord = fetchStart()
-
-        @Suppress("RegExpRedundantEscape")
-        val dataUriFindResult = dataUriRegex.find(urlNoQuery)
-        @Suppress("BlockingMethodInNonBlockingContext")
-        if (dataUriFindResult != null) {
-            val dataUriBase64 = dataUriFindResult.groupValues[1]
-            val byteArray = Base64.decode(dataUriBase64, Base64.DEFAULT)
-            fetchEnd(concurrentRecord)
-            return byteArray
-        } else {
+        try {
             setCookie(source?.getKey())
-            val byteArray = getProxyClient(proxy).newCallResponseBody(retry) {
+            @Suppress("BlockingMethodInNonBlockingContext")
+            val response = getProxyClient(proxy).newCallResponse(retry) {
                 addHeaders(headerMap)
                 when (method) {
                     RequestMethod.POST -> {
@@ -491,61 +450,70 @@ class AnalyzeUrl(
                     }
                     else -> get(urlNoQuery, fieldMap, true)
                 }
-            }.bytes()
+            }
+            return response
+        } finally {
             fetchEnd(concurrentRecord)
-            return byteArray
         }
     }
 
-    fun getByteArray(): ByteArray {
+    fun getResponse(): Response {
         return runBlocking {
-            getByteArrayAwait()
+            getResponseAwait()
         }
     }
 
     /**
-     * 访问网站,返回InputStream
+     * 访问网站,返回ByteArray
      */
-    suspend fun getInputStreamAwait(): InputStream {
+    @Suppress("UnnecessaryVariable")
+    suspend fun getByteArrayAwait(): ByteArray {
         val concurrentRecord = fetchStart()
-
-        @Suppress("RegExpRedundantEscape")
-        val dataUriFindResult = dataUriRegex.find(urlNoQuery)
-        @Suppress("BlockingMethodInNonBlockingContext")
-        if (dataUriFindResult != null) {
-            val dataUriBase64 = dataUriFindResult.groupValues[1]
-            val byteArray = Base64.decode(dataUriBase64, Base64.DEFAULT)
-            fetchEnd(concurrentRecord)
-            return ByteArrayInputStream(byteArray)
-        } else {
-            setCookie(source?.getKey())
-            val inputStream = getProxyClient(proxy).newCallResponseBody(retry) {
-                addHeaders(headerMap)
-                when (method) {
-                    RequestMethod.POST -> {
-                        url(urlNoQuery)
-                        val contentType = headerMap["Content-Type"]
-                        val body = body
-                        if (fieldMap.isNotEmpty() || body.isNullOrBlank()) {
-                            postForm(fieldMap, true)
-                        } else if (!contentType.isNullOrBlank()) {
-                            val requestBody = body.toRequestBody(contentType.toMediaType())
-                            post(requestBody)
-                        } else {
-                            postJson(body)
+        try {
+            @Suppress("RegExpRedundantEscape")
+            val dataUriFindResult = dataUriRegex.find(urlNoQuery)
+            @Suppress("BlockingMethodInNonBlockingContext")
+            if (dataUriFindResult != null) {
+                val dataUriBase64 = dataUriFindResult.groupValues[1]
+                val byteArray = Base64.decode(dataUriBase64, Base64.DEFAULT)
+                return byteArray
+            } else {
+                setCookie(source?.getKey())
+                val byteArray = getProxyClient(proxy).newCallResponseBody(retry) {
+                    addHeaders(headerMap)
+                    when (method) {
+                        RequestMethod.POST -> {
+                            url(urlNoQuery)
+                            val contentType = headerMap["Content-Type"]
+                            val body = body
+                            if (fieldMap.isNotEmpty() || body.isNullOrBlank()) {
+                                postForm(fieldMap, true)
+                            } else if (!contentType.isNullOrBlank()) {
+                                val requestBody = body.toRequestBody(contentType.toMediaType())
+                                post(requestBody)
+                            } else {
+                                postJson(body)
+                            }
                         }
+                        else -> get(urlNoQuery, fieldMap, true)
                     }
-                    else -> get(urlNoQuery, fieldMap, true)
-                }
-            }.byteStream()
+                }.bytes()
+                return byteArray
+            }
+        } finally {
             fetchEnd(concurrentRecord)
-            return inputStream
+        }
+    }
+
+    fun getByteArray(): ByteArray {
+        return runBlocking {
+            getByteArrayAwait()
         }
     }
 
     fun getInputStream(): InputStream {
         return runBlocking {
-            getInputStreamAwait()
+            getResponseAwait().body!!.byteStream()
         }
     }
 

+ 11 - 0
app/src/main/java/io/legado/app/service/BaseReadAloudService.kt

@@ -8,6 +8,7 @@ import android.content.Intent
 import android.content.IntentFilter
 import android.graphics.BitmapFactory
 import android.media.AudioManager
+import android.os.Bundle
 import android.os.PowerManager
 import android.support.v4.media.session.MediaSessionCompat
 import android.support.v4.media.session.PlaybackStateCompat
@@ -85,6 +86,7 @@ abstract class BaseReadAloudService : BaseService(),
         wakeLock.acquire()
         isRun = true
         pause = false
+        observeLiveBus()
         initMediaSession()
         initBroadcastReceiver()
         upNotification()
@@ -92,6 +94,15 @@ abstract class BaseReadAloudService : BaseService(),
         setTimer(AppConfig.ttsTimer)
     }
 
+    fun observeLiveBus() {
+        observeEvent<Bundle>(EventBus.READ_ALOUD_PLAY) {
+            val play = it.getBoolean("play")
+            val pageIndex = it.getInt("pageIndex")
+            val startPos = it.getInt("startPos")
+            newReadAloud(play, pageIndex, startPos)
+        }
+    }
+
     override fun onDestroy() {
         super.onDestroy()
         wakeLock.release()

+ 104 - 103
app/src/main/java/io/legado/app/service/HttpReadAloudService.kt

@@ -11,6 +11,7 @@ import io.legado.app.R
 import io.legado.app.constant.AppLog
 import io.legado.app.constant.AppPattern
 import io.legado.app.constant.EventBus
+import io.legado.app.data.entities.HttpTTS
 import io.legado.app.exception.ConcurrentException
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.config.AppConfig
@@ -20,9 +21,13 @@ import io.legado.app.model.ReadBook
 import io.legado.app.model.analyzeRule.AnalyzeUrl
 import io.legado.app.utils.*
 import kotlinx.coroutines.*
+import kotlinx.coroutines.Dispatchers.IO
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
 import okhttp3.Response
 import org.mozilla.javascript.WrappedException
 import java.io.File
+import java.io.InputStream
 import java.net.ConnectException
 import java.net.SocketTimeoutException
 
@@ -41,9 +46,9 @@ class HttpReadAloudService : BaseReadAloudService(),
     private var speechRate: Int = AppConfig.speechRatePlay + 5
     private var downloadTask: Coroutine<*>? = null
     private var playIndexJob: Job? = null
-    private var downloadTaskIsActive = false
     private var downloadErrorNo: Int = 0
     private var playErrorNo = 0
+    private val downloadTaskActiveLock = Mutex()
 
     override fun onCreate() {
         super.onCreate()
@@ -71,7 +76,7 @@ class HttpReadAloudService : BaseReadAloudService(),
                     val file = getSpeakFileAsMd5(fileName)
                     if (file.exists()) {
                         playAudio(file)
-                    } else if (!downloadTaskIsActive) {
+                    } else if (!downloadTaskActiveLock.isLocked) {
                         downloadAudio()
                     }
                 }
@@ -97,123 +102,111 @@ class HttpReadAloudService : BaseReadAloudService(),
     }
 
     private fun downloadAudio() {
-        launch {
-            downloadTask?.cancel()
-            while (downloadTaskIsActive) {
-                //在线tts大部分只能单线程,等待上次访问结束
-                delay(100)
-            }
-            downloadTask = execute {
+        downloadTask?.cancel()
+        downloadTask = execute {
+            downloadTaskActiveLock.withLock {
+                ensureActive()
                 removeCacheFile()
                 val httpTts = ReadAloud.httpTTS ?: throw NoStackTraceException("tts is null")
                 contentList.forEachIndexed { index, content ->
                     ensureActive()
                     val fileName = md5SpeakFileName(content)
                     val speakText = content.replace(AppPattern.notReadAloudRegex, "")
-                    if (hasSpeakFile(fileName)) { //已经下载好的语音缓存
-                        if (index == nowSpeak) {
-                            val file = getSpeakFileAsMd5(fileName)
-                            playAudio(file)
-                        }
-                    } else if (speakText.isEmpty()) {
-                        AppLog.put(
-                            "阅读段落内容为空,使用无声音频代替。\n朗读文本:$content"
-                        )
+                    if (speakText.isEmpty()) {
+                        AppLog.put("阅读段落内容为空,使用无声音频代替。\n朗读文本:$content")
                         createSilentSound(fileName)
-                        if (index == nowSpeak) {
-                            val file = getSpeakFileAsMd5(fileName)
-                            playAudio(file)
-                        }
-                        return@forEachIndexed
-                    } else {
+                    } else if (!hasSpeakFile(fileName)) {
                         runCatching {
-                            val analyzeUrl = AnalyzeUrl(
-                                httpTts.url,
-                                speakText = speakText,
-                                speakSpeed = speechRate,
-                                source = httpTts,
-                                headerMapF = httpTts.getHeaderMap(true)
-                            )
-                            var response = analyzeUrl.getResponseAwait()
-                            ensureActive()
-                            httpTts.loginCheckJs?.takeIf { checkJs ->
-                                checkJs.isNotBlank()
-                            }?.let { checkJs ->
-                                response = analyzeUrl.evalJS(checkJs, response) as Response
+                            val inputStream = getSpeakStream(httpTts, speakText)
+                            if (inputStream != null) {
+                                createSpeakFile(fileName, inputStream)
+                            } else {
+                                createSilentSound(fileName)
                             }
-                            httpTts.contentType?.takeIf { ct ->
-                                ct.isNotBlank()
-                            }?.let { ct ->
-                                response.headers["Content-Type"]?.let { contentType ->
-                                    if (!contentType.matches(ct.toRegex())) {
-                                        throw NoStackTraceException("TTS服务器返回错误:" + response.body!!.string())
-                                    }
-                                }
-                            }
-                            ensureActive()
-                            response.body!!.bytes().let { bytes ->
-                                val file = createSpeakFileAsMd5IfNotExist(fileName)
-                                file.writeBytes(bytes)
-                                if (index == nowSpeak) {
-                                    playAudio(file)
-                                }
-                            }
-                            downloadErrorNo = 0
                         }.onFailure {
                             when (it) {
                                 is CancellationException -> Unit
-                                is ConcurrentException -> {
-                                    delay(it.waitTime.toLong())
-                                    downloadAudio()
-                                }
-                                is ScriptException, is WrappedException -> {
-                                    AppLog.put("js错误\n${it.localizedMessage}", it)
-                                    toastOnUi("js错误\n${it.localizedMessage}")
-                                    it.printOnDebug()
-                                    cancel()
-                                    pauseReadAloud()
-                                }
-                                is SocketTimeoutException, is ConnectException -> {
-                                    downloadErrorNo++
-                                    if (downloadErrorNo > 5) {
-                                        val msg = "tts超时或连接错误超过5次\n${it.localizedMessage}"
-                                        AppLog.put(msg, it)
-                                        toastOnUi(msg)
-                                        pauseReadAloud()
-                                    } else {
-                                        downloadAudio()
-                                    }
-                                }
-                                else -> {
-                                    downloadErrorNo++
-                                    val msg = "tts下载错误\n${it.localizedMessage}"
-                                    AppLog.put(msg, it)
-                                    it.printOnDebug()
-                                    if (downloadErrorNo > 5) {
-                                        AppLog.put("TTS服务器连续5次错误,已暂停阅读。")
-                                        toastOnUi("TTS服务器连续5次错误,已暂停阅读。")
-                                        pauseReadAloud()
-                                    } else {
-                                        AppLog.put("TTS下载音频出错,使用无声音频代替。\n朗读文本:$content")
-                                        createSilentSound(fileName)
-                                        if (index == nowSpeak) {
-                                            val file = getSpeakFileAsMd5(fileName)
-                                            playAudio(file)
-                                        }
-                                    }
-                                }
+                                else -> pauseReadAloud()
                             }
+                            return@execute
+                        }
+                    }
+                    if (index == nowSpeak) {
+                        val file = getSpeakFileAsMd5(fileName)
+                        playAudio(file)
+                    }
+                }
+            }
+        }.onError(IO) {
+            AppLog.put("朗读下载出错\n${it.localizedMessage}", it)
+        }
+    }
+
+    private suspend fun getSpeakStream(httpTts: HttpTTS, speakText: String): InputStream? {
+        while (true) {
+            try {
+                val analyzeUrl = AnalyzeUrl(
+                    httpTts.url,
+                    speakText = speakText,
+                    speakSpeed = speechRate,
+                    source = httpTts,
+                    headerMapF = httpTts.getHeaderMap(true)
+                )
+                var response = analyzeUrl.getResponseAwait()
+                ensureActive()
+                val checkJs = httpTts.loginCheckJs
+                if (checkJs?.isNotBlank() == true) {
+                    response = analyzeUrl.evalJS(checkJs, response) as Response
+                }
+                val ct = httpTts.contentType
+                if (ct?.isNotBlank() == true) {
+                    response.headers["Content-Type"]?.let { contentType ->
+                        if (!contentType.matches(ct.toRegex())) {
+                            throw NoStackTraceException("TTS服务器返回错误:" + response.body!!.string())
+                        }
+                    }
+                }
+                ensureActive()
+                downloadErrorNo = 0
+                return response.body!!.byteStream()
+            } catch (e: Exception) {
+                when (e) {
+                    is CancellationException -> throw e
+                    is ConcurrentException -> delay(e.waitTime.toLong())
+                    is ScriptException, is WrappedException -> {
+                        AppLog.put("js错误\n${e.localizedMessage}", e)
+                        toastOnUi("js错误\n${e.localizedMessage}")
+                        e.printOnDebug()
+                        throw e
+                    }
+                    is SocketTimeoutException, is ConnectException -> {
+                        downloadErrorNo++
+                        if (downloadErrorNo > 5) {
+                            val msg = "tts超时或连接错误超过5次\n${e.localizedMessage}"
+                            AppLog.put(msg, e)
+                            toastOnUi(msg)
+                            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)
+                            toastOnUi(msg1)
+                            throw e
+                        } else {
+                            AppLog.put("TTS下载音频出错,使用无声音频代替。\n朗读文本:$speakText")
+                            break
                         }
                     }
                 }
-            }.onStart {
-                downloadTaskIsActive = true
-            }.onError {
-                AppLog.put("朗读下载出错\n${it.localizedMessage}", it)
-            }.onFinally {
-                downloadTaskIsActive = false
             }
         }
+        return null
     }
 
     @Synchronized
@@ -238,7 +231,7 @@ class HttpReadAloudService : BaseReadAloudService(),
     }
 
     private fun createSilentSound(fileName: String) {
-        val file = createSpeakFileAsMd5IfNotExist(fileName)
+        val file = createSpeakFile(fileName)
         file.writeBytes(resources.openRawResource(R.raw.silent_sound).readBytes())
     }
 
@@ -250,10 +243,18 @@ class HttpReadAloudService : BaseReadAloudService(),
         return File("${ttsFolderPath}$name.mp3")
     }
 
-    private fun createSpeakFileAsMd5IfNotExist(name: String): File {
+    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)
+            }
+        }
+    }
+
     /**
      * 移除缓存文件
      */

+ 3 - 0
app/src/main/java/io/legado/app/ui/book/read/config/MoreConfigDialog.kt

@@ -140,6 +140,9 @@ class MoreConfigDialog : DialogFragment() {
                 PreferKey.progressBarBehavior -> {
                     postEvent(EventBus.UP_SEEK_BAR, true)
                 }
+                PreferKey.noAnimScrollPage -> {
+                    ReadBook.callBack?.upPageAnim()
+                }
             }
         }
 

+ 1 - 2
app/src/main/java/io/legado/app/ui/book/read/config/ReadAloudDialog.kt

@@ -83,7 +83,6 @@ class ReadAloudDialog : BaseDialogFragment(R.layout.dialog_read_aloud) {
     private fun initData() = binding.run {
         upPlayState()
         upTimerText(BaseReadAloudService.timeMinute)
-        seekTimer.progress = BaseReadAloudService.timeMinute
         cbTtsFollowSys.isChecked = requireContext().getPrefBoolean("ttsFollowSys", true)
         upTtsSpeechRateEnabled(!cbTtsFollowSys.isChecked)
         upSeekTimer()
@@ -170,7 +169,7 @@ class ReadAloudDialog : BaseDialogFragment(R.layout.dialog_read_aloud) {
             if (BaseReadAloudService.timeMinute > 0) {
                 binding.seekTimer.progress = BaseReadAloudService.timeMinute
             } else {
-                binding.seekTimer.progress = 0
+                binding.seekTimer.progress = AppConfig.ttsTimer
             }
         }
     }

+ 1 - 0
app/src/main/java/io/legado/app/ui/book/read/page/ReadView.kt

@@ -503,6 +503,7 @@ class ReadView(context: Context, attrs: AttributeSet) :
                 pageDelegate = NoAnimPageDelegate(this)
             }
         }
+        (pageDelegate as? ScrollPageDelegate)?.noAnim = AppConfig.noAnimScrollPage
     }
 
     /**

+ 10 - 0
app/src/main/java/io/legado/app/ui/book/read/page/delegate/ScrollPageDelegate.kt

@@ -17,6 +17,8 @@ class ScrollPageDelegate(readView: ReadView) : PageDelegate(readView) {
     //速度追踪器
     private val mVelocity: VelocityTracker = VelocityTracker.obtain()
 
+    var noAnim: Boolean = false
+
     override fun onAnimStart(animationSpeed: Int) {
         //惯性滚动
         fling(
@@ -104,6 +106,10 @@ class ScrollPageDelegate(readView: ReadView) : PageDelegate(readView) {
         if (readView.isAbortAnim) {
             return
         }
+        if (noAnim) {
+            curPage.scroll(calcNextPageOffset())
+            return
+        }
         readView.setStartPoint(0f, 0f, false)
         startScroll(0, 0, 0, calcNextPageOffset(), animationSpeed)
     }
@@ -112,6 +118,10 @@ class ScrollPageDelegate(readView: ReadView) : PageDelegate(readView) {
         if (readView.isAbortAnim) {
             return
         }
+        if (noAnim) {
+            curPage.scroll(calcPrevPageOffset())
+            return
+        }
         readView.setStartPoint(0f, 0f, false)
         startScroll(0, 0, 0, calcPrevPageOffset(), animationSpeed)
     }

+ 5 - 2
app/src/main/java/io/legado/app/ui/config/BackupConfigFragment.kt

@@ -122,14 +122,17 @@ class BackupConfigFragment : PreferenceFragment(),
             it.setOnBindEditTextListener { editText ->
                 editText.text = AppConfig.webDavDir?.toEditable()
             }
-            it.setOnPreferenceChangeListener { _, newValue ->
-                (newValue as String).isNotBlank()
+        }
+        findPreference<EditTextPreference>(PreferKey.webDavDeviceName)?.let {
+            it.setOnBindEditTextListener { editText ->
+                editText.text = AppConfig.webDavDeviceName?.toEditable()
             }
         }
         upPreferenceSummary(PreferKey.webDavUrl, getPrefString(PreferKey.webDavUrl))
         upPreferenceSummary(PreferKey.webDavAccount, getPrefString(PreferKey.webDavAccount))
         upPreferenceSummary(PreferKey.webDavPassword, getPrefString(PreferKey.webDavPassword))
         upPreferenceSummary(PreferKey.webDavDir, AppConfig.webDavDir)
+        upPreferenceSummary(PreferKey.webDavDeviceName, AppConfig.webDavDeviceName)
         upPreferenceSummary(PreferKey.backupPath, getPrefString(PreferKey.backupPath))
         findPreference<io.legado.app.lib.prefs.Preference>("web_dav_restore")
             ?.onLongClick { restoreDir.launch(); true }

+ 25 - 0
app/src/main/java/io/legado/app/utils/EventBusExtensions.kt

@@ -4,6 +4,7 @@ package io.legado.app.utils
 
 import androidx.appcompat.app.AppCompatActivity
 import androidx.fragment.app.Fragment
+import androidx.lifecycle.LifecycleService
 import androidx.lifecycle.Observer
 import com.jeremyliao.liveeventbus.LiveEventBus
 import com.jeremyliao.liveeventbus.core.Observable
@@ -70,4 +71,28 @@ inline fun <reified EVENT> Fragment.observeEventSticky(
     tags.forEach {
         eventObservable<EVENT>(it).observeSticky(this, o)
     }
+}
+
+inline fun <reified EVENT> LifecycleService.observeEvent(
+    vararg tags: String,
+    noinline observer: (EVENT) -> Unit
+) {
+    val o = Observer<EVENT> {
+        observer(it)
+    }
+    tags.forEach {
+        eventObservable<EVENT>(it).observe(this, o)
+    }
+}
+
+inline fun <reified EVENT> LifecycleService.observeEventSticky(
+    vararg tags: String,
+    noinline observer: (EVENT) -> Unit
+) {
+    val o = Observer<EVENT> {
+        observer(it)
+    }
+    tags.forEach {
+        eventObservable<EVENT>(it).observeSticky(this, o)
+    }
 }

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

@@ -1043,4 +1043,6 @@
     <string name="search_scope">搜索范围</string>
     <string name="toggle_search_scope">切换</string>
     <string name="sure_clear_search_history">是否确认清除所有搜索历史记录</string>
+    <string name="no_anim_scroll_page">禁用滚动点击动画</string>
+    <string name="webdav_device_name">设备名称</string>
 </resources>

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

@@ -1046,4 +1046,6 @@
     <string name="search_scope">搜索范围</string>
     <string name="toggle_search_scope">切换</string>
     <string name="sure_clear_search_history">是否确认清除所有搜索历史记录</string>
+    <string name="no_anim_scroll_page">禁用滚动点击动画</string>
+    <string name="webdav_device_name">设备名称</string>
 </resources>

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

@@ -1046,4 +1046,6 @@
     <string name="search_scope">搜索范围</string>
     <string name="toggle_search_scope">切换</string>
     <string name="sure_clear_search_history">是否确认清除所有搜索历史记录</string>
+    <string name="no_anim_scroll_page">禁用滚动点击动画</string>
+    <string name="webdav_device_name">设备名称</string>
 </resources>

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

@@ -1043,4 +1043,6 @@
     <string name="search_scope">搜索范围</string>
     <string name="toggle_search_scope">切换</string>
     <string name="sure_clear_search_history">是否确认清除所有搜索历史记录</string>
+    <string name="no_anim_scroll_page">禁用滚动点击动画</string>
+    <string name="webdav_device_name">设备名称</string>
 </resources>

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

@@ -1045,4 +1045,6 @@
     <string name="search_scope">搜索范围</string>
     <string name="toggle_search_scope">切换</string>
     <string name="sure_clear_search_history">是否确认清除所有搜索历史记录</string>
+    <string name="no_anim_scroll_page">禁用滚动点击动画</string>
+    <string name="webdav_device_name">设备名称</string>
 </resources>

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

@@ -1045,4 +1045,6 @@
     <string name="search_scope">搜索范围</string>
     <string name="toggle_search_scope">切换</string>
     <string name="sure_clear_search_history">是否确认清除所有搜索历史记录</string>
+    <string name="no_anim_scroll_page">禁用滚动点击动画</string>
+    <string name="webdav_device_name">设备名称</string>
 </resources>

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

@@ -1046,4 +1046,6 @@
     <string name="search_scope">搜索范围</string>
     <string name="toggle_search_scope">切换</string>
     <string name="sure_clear_search_history">是否确认清除所有搜索历史记录</string>
+    <string name="no_anim_scroll_page">禁用滚动点击动画</string>
+    <string name="webdav_device_name">设备名称</string>
 </resources>

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

@@ -34,6 +34,13 @@
             app:allowDividerBelow="false"
             app:iconSpaceReserved="false" />
 
+        <io.legado.app.lib.prefs.EditTextPreference
+            android:key="webDavDeviceName"
+            android:title="@string/webdav_device_name"
+            app:allowDividerAbove="false"
+            app:allowDividerBelow="false"
+            app:iconSpaceReserved="false" />
+
         <io.legado.app.lib.prefs.SwitchPreference
             android:key="syncBookProgress"
             android:defaultValue="true"

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

@@ -136,6 +136,13 @@
         app:iconSpaceReserved="false"
         app:isBottomBackground="true" />
 
+    <io.legado.app.lib.prefs.SwitchPreference
+        android:defaultValue="false"
+        android:key="noAnimScrollPage"
+        android:title="@string/no_anim_scroll_page"
+        app:iconSpaceReserved="false"
+        app:isBottomBackground="true" />
+
     <io.legado.app.lib.prefs.Preference
         android:key="clickRegionalConfig"
         android:title="@string/click_regional_config"