This commit is contained in:
Horis 2022-10-19 17:53:24 +08:00
parent 98e7721354
commit c38b670b18
27 changed files with 324 additions and 242 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -68,7 +68,12 @@ object AppWebDav {
get() { get() {
val backupDate = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) val backupDate = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
.format(Date(System.currentTimeMillis())) .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() { suspend fun upConfig() {

View File

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

View File

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

View File

@ -2,6 +2,8 @@ package io.legado.app.model
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle
import io.legado.app.constant.EventBus
import io.legado.app.constant.IntentAction import io.legado.app.constant.IntentAction
import io.legado.app.data.appDb import io.legado.app.data.appDb
import io.legado.app.data.entities.HttpTTS 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.HttpReadAloudService
import io.legado.app.service.TTSReadAloudService import io.legado.app.service.TTSReadAloudService
import io.legado.app.utils.StringUtils import io.legado.app.utils.StringUtils
import io.legado.app.utils.postEvent
import splitties.init.appCtx import splitties.init.appCtx
object ReadAloud { object ReadAloud {
@ -50,6 +53,19 @@ object ReadAloud {
context.startService(intent) 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) { fun pause(context: Context) {
if (BaseReadAloudService.isRun) { if (BaseReadAloudService.isRun) {
val intent = Intent(context, aloudClass) val intent = Intent(context, aloudClass)

View File

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

View File

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

View File

@ -8,6 +8,7 @@ import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.media.AudioManager import android.media.AudioManager
import android.os.Bundle
import android.os.PowerManager import android.os.PowerManager
import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat import android.support.v4.media.session.PlaybackStateCompat
@ -85,6 +86,7 @@ abstract class BaseReadAloudService : BaseService(),
wakeLock.acquire() wakeLock.acquire()
isRun = true isRun = true
pause = false pause = false
observeLiveBus()
initMediaSession() initMediaSession()
initBroadcastReceiver() initBroadcastReceiver()
upNotification() upNotification()
@ -92,6 +94,15 @@ abstract class BaseReadAloudService : BaseService(),
setTimer(AppConfig.ttsTimer) 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() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
wakeLock.release() wakeLock.release()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -122,14 +122,17 @@ class BackupConfigFragment : PreferenceFragment(),
it.setOnBindEditTextListener { editText -> it.setOnBindEditTextListener { editText ->
editText.text = AppConfig.webDavDir?.toEditable() 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.webDavUrl, getPrefString(PreferKey.webDavUrl))
upPreferenceSummary(PreferKey.webDavAccount, getPrefString(PreferKey.webDavAccount)) upPreferenceSummary(PreferKey.webDavAccount, getPrefString(PreferKey.webDavAccount))
upPreferenceSummary(PreferKey.webDavPassword, getPrefString(PreferKey.webDavPassword)) upPreferenceSummary(PreferKey.webDavPassword, getPrefString(PreferKey.webDavPassword))
upPreferenceSummary(PreferKey.webDavDir, AppConfig.webDavDir) upPreferenceSummary(PreferKey.webDavDir, AppConfig.webDavDir)
upPreferenceSummary(PreferKey.webDavDeviceName, AppConfig.webDavDeviceName)
upPreferenceSummary(PreferKey.backupPath, getPrefString(PreferKey.backupPath)) upPreferenceSummary(PreferKey.backupPath, getPrefString(PreferKey.backupPath))
findPreference<io.legado.app.lib.prefs.Preference>("web_dav_restore") findPreference<io.legado.app.lib.prefs.Preference>("web_dav_restore")
?.onLongClick { restoreDir.launch(); true } ?.onLongClick { restoreDir.launch(); true }

View File

@ -4,6 +4,7 @@ package io.legado.app.utils
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import com.jeremyliao.liveeventbus.LiveEventBus import com.jeremyliao.liveeventbus.LiveEventBus
import com.jeremyliao.liveeventbus.core.Observable import com.jeremyliao.liveeventbus.core.Observable
@ -70,4 +71,28 @@ inline fun <reified EVENT> Fragment.observeEventSticky(
tags.forEach { tags.forEach {
eventObservable<EVENT>(it).observeSticky(this, o) 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)
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -34,6 +34,13 @@
app:allowDividerBelow="false" app:allowDividerBelow="false"
app:iconSpaceReserved="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 <io.legado.app.lib.prefs.SwitchPreference
android:key="syncBookProgress" android:key="syncBookProgress"
android:defaultValue="true" android:defaultValue="true"

View File

@ -136,6 +136,13 @@
app:iconSpaceReserved="false" app:iconSpaceReserved="false"
app:isBottomBackground="true" /> 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 <io.legado.app.lib.prefs.Preference
android:key="clickRegionalConfig" android:key="clickRegionalConfig"
android:title="@string/click_regional_config" android:title="@string/click_regional_config"