重做漫画功能,不影响现有逻辑漫画功能 (#4685)

* 增加漫画UI

* ...

* ...

* ...

* ...

* ...

* ...

* ...

* ...

* ...

* 完成加载图片人机验证问题

* 完成加载框

* ...

* ...

* ....

* ...

* ...

* ...

* ...

* ...

* ...

* ...

* ...

* ...

* 剔除重复章节

* ...

* ....

* ...

* ...

* ...

* ...

* ....

* ...

* ...

* ...

* ....

* ...

* ...

* ...

* ....

* ...

* ...

* ...

* ....

* ...

* ...

* ...

* ...

* ...

* ...

* ...

* ...

* ...

* ...

* ....

* ...

* ...

* ...

* ...

* ...

* ...

* ...

* ...

* ...

* ...

* ....

* ...

* ...

* ...

* ...

* ...

* ...

* ...

* ...

* ...

* ....

* ...

* ...

* ...

* ...

* ...

* ...

* ...

* ...

* ...

* ...

* ...
This commit is contained in:
lhjgege
2025-02-15 20:44:57 +08:00
committed by GitHub
parent af27b4e570
commit bcdbbcc562
58 changed files with 3373 additions and 49 deletions
+3
View File
@@ -238,6 +238,7 @@ dependencies {
//Glide
implementation(libs.glide.glide)
implementation(libs.glide.okhttp)
ksp(libs.glide.ksp)
//Svg
@@ -279,6 +280,8 @@ dependencies {
implementation platform(libs.firebase.bom)
implementation libs.firebase.analytics
implementation libs.firebase.perf
implementation libs.zoom.imageview
implementation libs.glide.recyclerview
//LeakCanary, 内存泄露检测
//debugImplementation('com.squareup.leakcanary:leakcanary-android:2.7')
+16
View File
@@ -24,6 +24,7 @@
<application
android:name=".App"
android:allowBackup="true"
android:largeHeap="true"
android:icon="@mipmap/ic_launcher"
android:label="${app_name}"
android:networkSecurityConfig="@xml/network_security_config"
@@ -134,6 +135,21 @@
android:name="com.samsung.android.support.REMOTE_ACTION"
android:resource="@xml/spen_remote_actions" />
</activity>
<!-- 阅读漫画界面 -->
<activity
android:name=".ui.book.manga.ReadMangaActivity"
android:configChanges="locale|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout"
android:exported="true"
android:launchMode="singleTask">
<intent-filter>
<action android:name="com.samsung.android.support.REMOTE_ACTION" />
</intent-filter>
<meta-data
android:name="com.samsung.android.support.REMOTE_ACTION"
android:resource="@xml/spen_remote_actions" />
</activity>
<!-- 书籍详情页 -->
<activity
android:name=".ui.book.info.BookInfoActivity"
+1 -1
View File
@@ -281,4 +281,4 @@ eGlhb3FpYW5nNTIw
dGlhbm1lbmd3ZW5rdQ==
YWlmdXNodQ==
bWlhb2R1NQ==
bWlmZW5neHM=
bWlmZW5neHM=
@@ -475,7 +475,7 @@ abstract class RecyclerAdapter<ITEM, VB : ViewBinding>(protected val context: Co
companion object {
private const val TYPE_HEADER_VIEW = Int.MIN_VALUE
private const val TYPE_FOOTER_VIEW = Int.MAX_VALUE - 999
const val TYPE_FOOTER_VIEW = Int.MAX_VALUE - 999
private val handler by lazy { buildMainHandler() }
}
@@ -153,6 +153,8 @@ object PreferKey {
const val streamReadAloudAudio = "streamReadAloudAudio"
const val pauseReadAloudWhilePhoneCalls = "pauseReadAloudWhilePhoneCalls"
const val readAloudByMediaButton = "readAloudByMediaButton"
const val showMangaUi="showMangaUi"
const val disableMangaScaling="disableMangaScaling"
const val cPrimary = "colorPrimary"
const val cAccent = "colorAccent"
@@ -604,5 +604,13 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
appCtx.toastOnUi("当前没有配置菜单区域,自动恢复中间区域为菜单.")
}
}
//跳转到漫画界面不使用富文本模式
val showMangaUi: Boolean
get() = appCtx.getPrefBoolean(PreferKey.showMangaUi, true)
//禁用漫画缩放
val disableMangaScaling: Boolean
get() = appCtx.getPrefBoolean(PreferKey.disableMangaScaling, true)
}
@@ -2,10 +2,16 @@ package io.legado.app.help.glide
import android.content.Context
import com.bumptech.glide.Glide
import com.bumptech.glide.GlideBuilder
import com.bumptech.glide.Registry
import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.load.engine.bitmap_recycle.LruBitmapPool
import com.bumptech.glide.load.engine.cache.InternalCacheDiskCacheFactory
import com.bumptech.glide.load.engine.cache.LruResourceCache
import com.bumptech.glide.load.model.GlideUrl
import com.bumptech.glide.module.AppGlideModule
import com.bumptech.glide.request.RequestOptions
import java.io.InputStream
@@ -13,7 +19,6 @@ import java.io.InputStream
@GlideModule
class LegadoGlideModule : AppGlideModule() {
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
registry.replace(
GlideUrl::class.java,
@@ -22,4 +27,19 @@ class LegadoGlideModule : AppGlideModule() {
)
}
override fun applyOptions(context: Context, builder: GlideBuilder) {
super.applyOptions(context, builder)
builder.setMemoryCache(LruResourceCache(1024 * 1024 * 500))
.setBitmapPool(LruBitmapPool(1024 * 1024 * 200))
.setDiskCache(
InternalCacheDiskCacheFactory(
context,
1024 * 1024 * 1000
)
)
.setDefaultRequestOptions {
RequestOptions().format(DecodeFormat.PREFER_RGB_565)
.encodeQuality(90)
}
}
}
@@ -4,10 +4,11 @@ import com.bumptech.glide.load.model.GlideUrl
import com.bumptech.glide.load.model.ModelLoader
import com.bumptech.glide.load.model.ModelLoaderFactory
import com.bumptech.glide.load.model.MultiModelLoaderFactory
import okhttp3.Call
import java.io.InputStream
object OkHttpModeLoaderFactory : ModelLoaderFactory<GlideUrl?, InputStream?> {
object OkHttpModeLoaderFactory: ModelLoaderFactory<GlideUrl?, InputStream?> {
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<GlideUrl?, InputStream?> {
return OkHttpModelLoader
@@ -6,26 +6,29 @@ import com.bumptech.glide.load.model.GlideUrl
import com.bumptech.glide.load.model.ModelLoader
import io.legado.app.model.analyzeRule.AnalyzeUrl
import io.legado.app.utils.isAbsUrl
import java.io.InputStream
object OkHttpModelLoader : ModelLoader<GlideUrl?, InputStream?> {
val loadOnlyWifiOption = Option.memory("loadOnlyWifi", false)
val sourceOriginOption = Option.memory<String>("sourceOrigin")
val mangaOption = Option.memory<Boolean>("manga",false)
override fun buildLoadData(
model: GlideUrl,
width: Int,
height: Int,
options: Options
options: Options,
): ModelLoader.LoadData<InputStream?> {
val cacheKey = model.toString()
var modelWithHeader = model
if (cacheKey.isAbsUrl()) {
modelWithHeader = AnalyzeUrl(cacheKey).getGlideUrl()
}
return ModelLoader.LoadData(modelWithHeader, OkHttpStreamFetcher(modelWithHeader, options))
return ModelLoader.LoadData(
modelWithHeader,
OkHttpStreamFetcher(modelWithHeader, options)
)
}
override fun handles(model: GlideUrl): Boolean {
@@ -14,6 +14,7 @@ import io.legado.app.help.http.CookieManager.cookieJarHeader
import io.legado.app.help.http.addHeaders
import io.legado.app.help.http.okHttpClient
import io.legado.app.help.source.SourceHelp
import io.legado.app.model.ReadMange
import io.legado.app.utils.ImageUtils
import io.legado.app.utils.isWifiConnect
import okhttp3.Call
@@ -26,7 +27,10 @@ import java.io.IOException
import java.io.InputStream
class OkHttpStreamFetcher(private val url: GlideUrl, private val options: Options) :
class OkHttpStreamFetcher(
private val url: GlideUrl,
private val options: Options,
) :
DataFetcher<InputStream>, okhttp3.Callback {
private var stream: InputStream? = null
private var responseBody: ResponseBody? = null
@@ -95,15 +99,31 @@ class OkHttpStreamFetcher(private val url: GlideUrl, private val options: Option
override fun onResponse(call: Call, response: Response) {
responseBody = response.body
if (response.isSuccessful) {
val decodeResult = ImageUtils.decode(
val manga = options.get(OkHttpModelLoader.mangaOption) == true
val decodeResult = if (manga) {
ImageUtils.decode(
url.toStringUrl(),
responseBody!!.byteStream().readBytes(),
isCover = false,
source,
ReadMange.book
).let {
ByteArrayInputStream(it)
}
} else {
ImageUtils.decode(
url.toStringUrl(), responseBody!!.byteStream(),
isCover = true, source
)
}
if (response.isSuccessful) {
if (decodeResult == null) {
callback?.onLoadFailed(NoStackTraceException("封面二次解密失败"))
} else {
val contentLength: Long = if (decodeResult is ByteArrayInputStream) decodeResult.available().toLong() else Preconditions.checkNotNull(responseBody).contentLength()
val contentLength: Long =
if (decodeResult is ByteArrayInputStream) decodeResult.available()
.toLong() else Preconditions.checkNotNull(responseBody).contentLength()
stream = ContentLengthInputStream.obtain(decodeResult, contentLength)
callback?.onDataReady(stream)
}
@@ -0,0 +1,3 @@
package io.legado.app.help.glide.progress
typealias OnProgressListener = ((isComplete: Boolean, percentage: Int, bytesRead: Long, totalBytes: Long) -> Unit)?
@@ -0,0 +1,51 @@
package io.legado.app.help.glide.progress
import android.text.TextUtils
import io.legado.app.utils.runOnUI
import java.util.concurrent.ConcurrentHashMap
/**
* 进度监听器管理类
* 加入图片加载进度监听,加入Https支持
*/
object ProgressManager {
private val listenersMap = ConcurrentHashMap<String, OnProgressListener>()
val LISTENER = object : ProgressResponseBody.InternalProgressListener {
override fun onProgress(url: String, bytesRead: Long, totalBytes: Long) {
getProgressListener(url)?.let {
var percentage = (bytesRead * 1f / totalBytes * 100f).toInt()
var isComplete = percentage >= 100
if (percentage <= -100) {
percentage = 0
isComplete = true
}
runOnUI {
it.invoke(isComplete, percentage, bytesRead, totalBytes)
}
if (isComplete) {
removeListener(url)
}
}
}
}
fun addListener(url: String, listener: OnProgressListener) {
if (!TextUtils.isEmpty(url) && listener != null) {
listenersMap[url] = listener
listener.invoke(false, 1, 0, 0)
}
}
fun removeListener(url: String) {
if (!TextUtils.isEmpty(url)) {
listenersMap.remove(url)
}
}
fun getProgressListener(url: String?): OnProgressListener {
return if (TextUtils.isEmpty(url) || listenersMap.size == 0) {
null
} else listenersMap[url]
}
}
@@ -0,0 +1,54 @@
package io.legado.app.help.glide.progress
import android.os.Handler
import android.os.Looper
import okhttp3.MediaType
import okhttp3.ResponseBody
import okio.*
import java.io.IOException
import kotlin.jvm.Throws
class ProgressResponseBody internal constructor(private val url: String, private val internalProgressListener: InternalProgressListener?, private val responseBody: ResponseBody) : ResponseBody() {
private var bufferedSource: BufferedSource? = null
override fun contentType(): MediaType? {
return responseBody.contentType()
}
override fun contentLength(): Long {
return responseBody.contentLength()
}
override fun source(): BufferedSource {
if (bufferedSource == null) {
bufferedSource = source(responseBody.source()).buffer()
}
return bufferedSource!!
}
private fun source(source: Source): Source {
return object : ForwardingSource(source) {
var totalBytesRead: Long = 0
var lastTotalBytesRead: Long = 0
@Throws(IOException::class)
override fun read(sink: Buffer, byteCount: Long): Long {
val bytesRead = super.read(sink, byteCount)
totalBytesRead += if (bytesRead == -1L) 0 else bytesRead
if (internalProgressListener != null && lastTotalBytesRead != totalBytesRead) {
lastTotalBytesRead = totalBytesRead
mainThreadHandler.post { internalProgressListener.onProgress(url, totalBytesRead, contentLength()) }
}
return bytesRead
}
}
}
interface InternalProgressListener {
fun onProgress(url: String, bytesRead: Long, totalBytes: Long)
}
companion object {
private val mainThreadHandler = Handler(Looper.getMainLooper())
}
}
@@ -3,6 +3,9 @@ package io.legado.app.help.http
import io.legado.app.constant.AppConst
import io.legado.app.help.CacheManager
import io.legado.app.help.config.AppConfig
import io.legado.app.help.glide.progress.ProgressManager
import io.legado.app.help.glide.progress.ProgressManager.LISTENER
import io.legado.app.help.glide.progress.ProgressResponseBody
import io.legado.app.help.http.CookieManager.cookieJarHeader
import io.legado.app.utils.NetworkUtils
import okhttp3.ConnectionSpec
@@ -93,7 +96,15 @@ val okHttpClient: OkHttpClient by lazy {
if (enableCookieJar) {
CookieManager.saveResponse(networkResponse)
}
networkResponse
networkResponse.newBuilder()
.body(
ProgressResponseBody(
request.url.toString(),
LISTENER,
networkResponse.body!!
)
)
.build()
}
if (AppConfig.isCronet) {
if (Cronet.loader?.install() == true) {
@@ -6,9 +6,11 @@ import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import androidx.annotation.Keep
import com.bumptech.glide.RequestBuilder
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.load.resource.bitmap.CenterCrop
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.request.target.Target.SIZE_ORIGINAL
import io.legado.app.R
import io.legado.app.constant.PreferKey
import io.legado.app.data.entities.BaseSource
@@ -21,7 +23,11 @@ import io.legado.app.help.glide.ImageLoader
import io.legado.app.help.glide.OkHttpModelLoader
import io.legado.app.model.analyzeRule.AnalyzeRule
import io.legado.app.model.analyzeRule.AnalyzeUrl
import io.legado.app.utils.*
import io.legado.app.utils.BitmapUtils
import io.legado.app.utils.GSON
import io.legado.app.utils.fromJsonObject
import io.legado.app.utils.getPrefBoolean
import io.legado.app.utils.getPrefString
import splitties.init.appCtx
@Keep
@@ -71,7 +77,7 @@ object BookCover {
context: Context,
path: String?,
loadOnlyWifi: Boolean = false,
sourceOrigin: String? = null
sourceOrigin: String? = null,
): RequestBuilder<Drawable> {
if (AppConfig.useDefaultCover) {
return ImageLoader.load(context, defaultDrawable)
@@ -88,6 +94,31 @@ object BookCover {
.centerCrop()
}
/**
* 加载漫画图片
*/
fun loadManga(
context: Context,
path: String?,
loadOnlyWifi: Boolean = false,
sourceOrigin: String? = null,
manga: Boolean = false,
useDefaultCover: Drawable? = null,
): RequestBuilder<Drawable> {
var options = RequestOptions().set(OkHttpModelLoader.loadOnlyWifiOption, loadOnlyWifi)
.set(OkHttpModelLoader.mangaOption, manga)
if (sourceOrigin != null) {
options = options.set(OkHttpModelLoader.sourceOriginOption, sourceOrigin)
}
return ImageLoader.load(context, path)
.apply(options)
.override(context.resources.displayMetrics.widthPixels, SIZE_ORIGINAL)
.placeholder(useDefaultCover)
.error(useDefaultCover)
.diskCacheStrategy(DiskCacheStrategy.ALL)
}
/**
* 加载模糊封面
*/
@@ -95,7 +126,7 @@ object BookCover {
context: Context,
path: String?,
loadOnlyWifi: Boolean = false,
sourceOrigin: String? = null
sourceOrigin: String? = null,
): RequestBuilder<Drawable> {
val loadBlur = ImageLoader.load(context, defaultDrawable)
.transform(BlurTransformation(25), CenterCrop())
@@ -0,0 +1,638 @@
package io.legado.app.model
import io.legado.app.constant.AppLog
import io.legado.app.constant.AppPattern
import io.legado.app.data.appDb
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.BookProgress
import io.legado.app.data.entities.BookSource
import io.legado.app.data.entities.ReadRecord
import io.legado.app.help.AppWebDav
import io.legado.app.help.book.BookHelp
import io.legado.app.help.book.ContentProcessor
import io.legado.app.help.book.isLocal
import io.legado.app.help.book.readSimulating
import io.legado.app.help.book.simulatedTotalChapterNum
import io.legado.app.help.book.update
import io.legado.app.help.config.AppConfig
import io.legado.app.help.coroutine.Coroutine
import io.legado.app.help.globalExecutor
import io.legado.app.model.recyclerView.MangeContent
import io.legado.app.model.recyclerView.ReaderLoading
import io.legado.app.model.webBook.WebBook
import io.legado.app.utils.NetworkUtils
import io.legado.app.utils.mapIndexed
import io.legado.app.utils.runOnUI
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlin.math.min
@Suppress("MemberVisibilityCanBePrivate")
object ReadMange : CoroutineScope by MainScope() {
var inBookshelf = false
var tocChanged = false
var chapterChanged = false
var book: Book? = null
val executor = globalExecutor
var durChapterPagePos = 0 //章节位置
var durChapterPageCount = 0//总章节
var durChapterCount = 0
var durChapterPos = 0
var bookSource: BookSource? = null
var chapterTitle: String = ""
var readStartTime: Long = System.currentTimeMillis()
private val readRecord = ReadRecord()
private val loadingChapters = arrayListOf<Int>()
var simulatedChapterSize = 0
var mCallback: Callback? = null
var mFirstLoading = false
var gameOver = false
var mTopChapter: BookChapter? = null
var preDownloadTask: Job? = null
val downloadedChapters = hashSetOf<Int>()
val downloadFailChapters = hashMapOf<Int, Int>()
private val downloadLoadingChapters = arrayListOf<Int>()
var isMangaMode = false
val downloadScope = CoroutineScope(SupervisorJob() + IO)
fun saveRead(pageChanged: Boolean = false) {
executor.execute {
val book = ReadMange.book ?: return@execute
book.lastCheckCount = 0
book.durChapterTime = System.currentTimeMillis()
val chapterChanged = book.durChapterIndex != durChapterPagePos
book.durChapterIndex = durChapterPagePos
book.durChapterPos = durChapterPos
if (!pageChanged || chapterChanged) {
appDb.bookChapterDao.getChapter(book.bookUrl, durChapterPagePos)?.let {
book.durChapterTitle = it.getDisplayTitle(
ContentProcessor.get(book.name, book.origin).getTitleReplaceRules(),
book.getUseReplaceRule()
)
}
}
appDb.bookDao.update(book)
}
}
fun upData(book: Book) {
ReadMange.book = book
isMangaMode = true
durChapterPageCount = appDb.bookChapterDao.getChapterCount(book.bookUrl)
simulatedChapterSize = if (book.readSimulating()) {
book.simulatedTotalChapterNum()
} else {
durChapterPageCount
}
if (durChapterPagePos != book.durChapterIndex || tocChanged) {
durChapterPagePos = book.durChapterIndex
durChapterPos = book.durChapterPos
}
upWebBook(book)
synchronized(this) {
loadingChapters.clear()
}
}
fun resetData(book: Book) {
ReadMange.book = book
isMangaMode = true
readRecord.bookName = book.name
readRecord.readTime = appDb.readRecordDao.getReadTime(book.name) ?: 0
durChapterPageCount = appDb.bookChapterDao.getChapterCount(book.bookUrl)
simulatedChapterSize = if (book.readSimulating()) {
book.simulatedTotalChapterNum()
} else {
durChapterPageCount
}
durChapterPagePos = book.durChapterIndex
durChapterPos = book.durChapterPos
upWebBook(book)
synchronized(this) {
loadingChapters.clear()
}
}
fun upWebBook(book: Book) {
appDb.bookSourceDao.getBookSource(book.origin)?.let {
bookSource = it
} ?: let {
bookSource = null
}
}
//每次切换章节更新阅读记录
fun upReadTime() {
executor.execute {
if (!AppConfig.enableReadRecord) {
return@execute
}
readRecord.readTime = readRecord.readTime + System.currentTimeMillis() - readStartTime
readStartTime = System.currentTimeMillis()
readRecord.lastRead = System.currentTimeMillis()
appDb.readRecordDao.insert(readRecord)
}
}
private fun addLoading(index: Int): Boolean {
synchronized(this) {
if (loadingChapters.contains(index)) return false
loadingChapters.add(index)
return true
}
}
private fun addDownloadLoading(index: Int): Boolean {
synchronized(this) {
if (downloadLoadingChapters.contains(index)) return false
downloadLoadingChapters.add(index)
return true
}
}
fun removeDownloadLoading(index: Int) {
synchronized(this) {
downloadLoadingChapters.remove(index)
}
}
fun removeLoading(index: Int) {
synchronized(this) {
loadingChapters.remove(index)
}
}
fun loading(index: Int): Boolean {
synchronized(this) {
return loadingChapters.contains(index)
}
}
/**
* 获取正文
*/
private suspend fun getContent(
scope: CoroutineScope,
chapter: BookChapter,
) {
val book = ReadMange.book ?: return removeLoading(chapter.index)
val bookSource = ReadMange.bookSource
if (bookSource != null) {
getContent(bookSource, scope, chapter, book)
}
}
fun loadContent() {
loadContent(durChapterPagePos)
}
fun loadContent(
index: Int,
) {
if (addLoading(index)) {
Coroutine.async {
val book = ReadMange.book!!
appDb.bookChapterDao.getChapter(book.bookUrl, index)?.let { chapter ->
getContent(
downloadScope,
chapter,
)
} ?: removeLoading(index)
}.onError {
removeLoading(index)
AppLog.put("加载正文出错\n${it.localizedMessage}")
}
}
}
/**
* 注册回调
*/
fun register(cb: Callback) {
mCallback = cb
}
/**
* 取消注册回调
*/
fun unregister() {
inBookshelf = false
tocChanged = false
chapterChanged = false
book = null
durChapterPagePos = 0
isMangaMode = false
durChapterPageCount = 0
durChapterPos = 0
durChapterCount = 0
bookSource = null
chapterTitle = ""
loadingChapters.clear()
simulatedChapterSize = 0
mCallback = null
mFirstLoading = false
gameOver = false
mTopChapter = null
preDownloadTask?.cancel()
preDownloadTask = null
downloadedChapters.clear()
downloadFailChapters.clear()
downloadLoadingChapters.clear()
executor.asCoroutineDispatcher().cancelChildren()
downloadScope.coroutineContext.cancelChildren()
coroutineContext.cancelChildren()
}
/**
* 内容加载完成
*/
suspend fun contentLoadFinish(
chapter: BookChapter,
content: String,
book: Book
) {
if (mTopChapter != null && mTopChapter!!.title != chapterTitle && BookHelp.hasContent(
book,
mTopChapter!!
)
) {
BookHelp.delContent(book, chapter)
}
mTopChapter = chapter
chapterTitle = chapter.title
if (chapter.index !in durChapterPagePos - 1..durChapterPagePos + 1) {
return
}
if (content.isNotEmpty()) {
val list = flow {
val matcher = AppPattern.imgPattern.matcher(content)
while (matcher.find()) {
val src = matcher.group(1) ?: continue
val mSrc = NetworkUtils.getAbsoluteURL(chapter.url, src)
emit(mSrc)
}
}.distinctUntilChangedBy {
it
}.mapIndexed { index, src ->
MangeContent(
mChapterPageCount = durChapterPageCount,
mChapterPagePos = durChapterPagePos,
mChapterNextPagePos = durChapterPagePos.plus(1),
mImageUrl = src,
mDurChapterPos = index.plus(1)
)
}.toList().apply {
this.forEach {
it.mDurChapterCount = this.size
}
}
val contentList = mutableListOf<Any>()
contentList.add(
ReaderLoading(
durChapterPagePos,
"阅读 ${chapter.title}",
mNextChapterIndex = durChapterPagePos.plus(1)
)
)
contentList.addAll(list)
durChapterCount = contentList.size
contentList.add(
ReaderLoading(
durChapterPagePos,
"已读完 ${chapter.title}",
mNextChapterIndex = durChapterPagePos.plus(1)
)
)
runOnUI {
mCallback?.loadContentFinish(contentList)
}
}
}
/**
* 加载下一章
*/
fun moveToNextChapter(index: Int) {
if (loading(index)) {
return
}
if (index > durChapterPageCount - 1) {
upToc(index)
return
}
if (durChapterPagePos < simulatedChapterSize - 1) {
durChapterPos = 0
durChapterPagePos = index
saveRead()
loadContent(durChapterPagePos)
AppLog.putDebug("moveToNextChapter-curPageChanged()")
curPageChanged()
} else {
AppLog.putDebug("跳转下一章失败,没有下一章")
}
}
fun curPageChanged() {
upReadTime()
preDownload()
}
@Synchronized
fun upToc(index: Int) {
val bookSource = bookSource ?: return
val book = book ?: return
if (!book.canUpdate) return
if (System.currentTimeMillis() - book.lastCheckTime < 600000) return
book.lastCheckTime = System.currentTimeMillis()
WebBook.getChapterList(this, bookSource, book).onSuccess(IO) { cList ->
if (book.bookUrl == ReadMange.book?.bookUrl
&& cList.size > durChapterPageCount
) {
appDb.bookChapterDao.delByBook(book.bookUrl)
appDb.bookChapterDao.insert(*cList.toTypedArray())
saveRead()
durChapterPos = 0
durChapterPagePos = index
durChapterPageCount = cList.size
simulatedChapterSize = book.simulatedTotalChapterNum()
loadContent(durChapterPagePos)
} else {
durChapterPagePos = durChapterPagePos.minus(1)
saveRead()
gameOver = true
runOnUI {
mCallback?.noData()
}
}
}
}
private suspend fun getContent(
bookSource: BookSource,
scope: CoroutineScope,
chapter: BookChapter,
book: Book,
) {
if (BookHelp.hasContent(book, chapter)) {
BookHelp.getContent(book, chapter)?.apply {
contentLoadFinish(chapter, this, book)
runOnUI {
mCallback?.loadComplete()
}
if (durChapterPagePos >= durChapterPageCount.minus(1)) {
gameOver = true
runOnUI {
mCallback?.noData()
}
}
} ?: downloadNetworkContent(bookSource, scope, chapter, book, success = {
contentLoadFinish(chapter, it, book)
runOnUI {
mCallback?.loadComplete()
}
}, error = {
removeLoading(chapter.index)
runOnUI {
mCallback?.loadFail("加载内容失败")
}
})
} else {
downloadNetworkContent(bookSource, scope, chapter, book, success = {
contentLoadFinish(chapter, it, book)
runOnUI {
mCallback?.loadComplete()
}
}, error = {
removeLoading(chapter.index)
runOnUI {
mCallback?.loadFail("加载内容失败")
}
})
}
}
private fun downloadNetworkContent(
bookSource: BookSource,
scope: CoroutineScope,
chapter: BookChapter,
book: Book,
success: suspend (String) -> Unit = {},
error: suspend () -> Unit = {},
) {
WebBook.getContent(
scope,
bookSource,
book,
chapter,
start = CoroutineStart.LAZY,
executeContext = IO,
needSave = true
).onSuccess { content ->
success.invoke(content)
}.onError {
error.invoke()
}.start()
}
private fun preDownload() {
if (book?.isLocal == true) return
executor.execute {
if (AppConfig.preDownloadNum < 2) {
return@execute
}
preDownloadTask?.cancel()
preDownloadTask = launch(IO) {
//预下载
launch {
val maxChapterIndex =
min(durChapterPagePos + AppConfig.preDownloadNum, durChapterPageCount)
for (i in durChapterPagePos.plus(2)..maxChapterIndex) {
if (downloadedChapters.contains(i)) continue
if ((downloadFailChapters[i] ?: 0) >= 3) continue
downloadIndex(i)
}
}
launch {
val minChapterIndex = durChapterPagePos - min(5, AppConfig.preDownloadNum)
for (i in durChapterPagePos.minus(2) downTo minChapterIndex) {
if (downloadedChapters.contains(i)) continue
if ((downloadFailChapters[i] ?: 0) >= 3) continue
downloadIndex(i)
}
}
}
}
}
private suspend fun downloadIndex(index: Int) {
if (index < 0) return
if (index > durChapterPageCount - 1) {
upToc()
return
}
val book = book ?: return
if (addDownloadLoading(index)) {
try {
appDb.bookChapterDao.getChapter(book.bookUrl, index)?.let { chapter ->
if (BookHelp.hasContent(book, chapter)) {
removeDownloadLoading(chapter.index)
downloadedChapters.add(chapter.index)
} else {
delay(1000)
downloadNetworkContent(
bookSource!!,
downloadScope,
chapter,
book,
success = {
downloadedChapters.add(chapter.index)
downloadFailChapters.remove(chapter.index)
},
error = {
downloadFailChapters[chapter.index] =
(downloadFailChapters[chapter.index] ?: 0) + 1
removeDownloadLoading(chapter.index)
})
}
} ?: removeDownloadLoading(index)
} catch (e: Exception) {
removeLoading(index)
}
}
}
@Synchronized
fun upToc() {
val bookSource = bookSource ?: return
val book = book ?: return
if (!book.canUpdate) return
if (System.currentTimeMillis() - book.lastCheckTime < 600000) return
book.lastCheckTime = System.currentTimeMillis()
WebBook.getChapterList(this, bookSource, book).onSuccess(IO) { cList ->
if (book.bookUrl == ReadMange.book?.bookUrl
&& cList.size > durChapterPageCount
) {
appDb.bookChapterDao.delByBook(book.bookUrl)
appDb.bookChapterDao.insert(*cList.toTypedArray())
saveRead()
durChapterPageCount = cList.size
simulatedChapterSize = book.simulatedTotalChapterNum()
}
}
}
fun uploadProgress(successAction: (() -> Unit)? = null) {
book?.let {
launch(IO) {
AppWebDav.uploadBookProgress(it)
ensureActive()
it.update()
successAction?.invoke()
}
}
}
/**
* 同步阅读进度
* 如果当前进度快于服务器进度或者没有进度进行上传,如果慢与服务器进度则执行传入动作
*/
fun syncProgress(
newProgressAction: ((progress: BookProgress) -> Unit)? = null,
uploadSuccessAction: (() -> Unit)? = null,
syncSuccessAction: (() -> Unit)? = null
) {
if (!AppConfig.syncBookProgress) return
book?.let {
Coroutine.async {
AppWebDav.getBookProgress(it)
}.onError {
AppLog.put("拉取阅读进度失败", it)
}.onSuccess { progress ->
if (progress == null || progress.durChapterIndex < it.durChapterIndex ||
(progress.durChapterIndex == it.durChapterIndex
&& progress.durChapterPos < it.durChapterPos)
) {
// 服务器没有进度或者进度比服务器快,上传现有进度
Coroutine.async {
AppWebDav.uploadBookProgress(BookProgress(it), uploadSuccessAction)
it.update()
}
} else if (progress.durChapterIndex > it.durChapterIndex ||
progress.durChapterPos > it.durChapterPos
) {
// 进度比服务器慢,执行传入动作
newProgressAction?.invoke(progress)
} else {
syncSuccessAction?.invoke()
}
}
}
}
fun setProgress(progress: BookProgress) {
if (progress.durChapterIndex < durChapterPageCount &&
(durChapterPagePos != progress.durChapterIndex
|| durChapterPos != progress.durChapterPos)
) {
chapterChanged = true
if (progress.durChapterIndex == durChapterPagePos) {
durChapterPos = progress.durChapterPos
mCallback?.adjustmentProgress()
} else {
durChapterPagePos = progress.durChapterIndex
durChapterPos = progress.durChapterPos
if (addLoading(durChapterPagePos)) {
loadContent(durChapterPagePos)
} else {
Coroutine.async {
val book = ReadMange.book!!
appDb.bookChapterDao.getChapter(book.bookUrl, durChapterPagePos)
?.let { chapter ->
getContent(
downloadScope,
chapter,
)
}
}.onError {
AppLog.put("加载正文出错\n${it.localizedMessage}")
}
}
}
saveRead()
}
}
interface Callback {
fun loadContentFinish(list: MutableList<Any>)
fun loadComplete()
fun loadFail(msg: String)
fun noData()
fun adjustmentProgress()
fun sureNewProgress(progress: BookProgress)
val chapterList: MutableList<Any>
}
}
@@ -0,0 +1,116 @@
package io.legado.app.model.recyclerView
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.drawable.Drawable
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.Button
import android.widget.FrameLayout
import android.widget.ProgressBar
import android.widget.TextView
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target
import com.github.panpf.zoomimage.GlideZoomImageView
import io.legado.app.R
import io.legado.app.help.glide.progress.OnProgressListener
import io.legado.app.help.glide.progress.ProgressManager
import io.legado.app.model.BookCover
import io.legado.app.model.ReadMange
import io.legado.app.utils.getCompatDrawable
import io.legado.app.utils.printOnDebug
open class MangaVH<VB : ViewBinding>(val binding: VB, private val context: Context) :
RecyclerView.ViewHolder(binding.root) {
protected lateinit var mLoading: ProgressBar
protected lateinit var mImage: GlideZoomImageView
protected lateinit var mProgress: TextView
protected lateinit var mFlProgress: FrameLayout
protected var mRetry: Button? = null
fun initComponent(
loading: ProgressBar,
image: GlideZoomImageView,
progress: TextView,
button: Button? = null,
flProgress: FrameLayout,
) {
mLoading = loading
mImage = image
mRetry = button
mProgress = progress
mFlProgress = flProgress
}
@SuppressLint("CheckResult")
fun loadImageWithRetry(imageUrl: String) {
mFlProgress.isVisible = true
mLoading.isVisible = true
mRetry?.isGone = true
mProgress.isVisible = true
ProgressManager.removeListener(imageUrl)
ProgressManager.addListener(imageUrl, object : OnProgressListener {
@SuppressLint("SetTextI18n")
override fun invoke(
isComplete: Boolean,
percentage: Int,
bytesRead: Long,
totalBytes: Long,
) {
mProgress.text = "${percentage}%"
}
})
try {
mImage.tag = imageUrl
BookCover.loadManga(
context,
imageUrl,
sourceOrigin = ReadMange.book?.origin,
manga = true,
useDefaultCover = context.getCompatDrawable(R.color.book_ant_10)
).addListener(object : RequestListener<Drawable> {
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<Drawable>,
isFirstResource: Boolean,
): Boolean {
mFlProgress.isVisible = true
mLoading.isGone = true
mRetry?.isVisible = true
mProgress.isGone = true
itemView.updateLayoutParams<ViewGroup.LayoutParams> {
height = MATCH_PARENT
}
return false
}
override fun onResourceReady(
resource: Drawable,
model: Any,
target: Target<Drawable>?,
dataSource: DataSource,
isFirstResource: Boolean,
): Boolean {
mFlProgress.isGone = true
itemView.updateLayoutParams<ViewGroup.LayoutParams> {
height = WRAP_CONTENT
}
return false
}
}).into(mImage)
} catch (e: Exception) {
e.printOnDebug()
}
}
}
@@ -0,0 +1,11 @@
package io.legado.app.model.recyclerView
data class MangeContent(
var mChapterPagePos: Int = 0,//总章节位置
var mChapterPageCount:Int,//总章节数量
var mChapterNextPagePos: Int = 0,//下一章
val mImageUrl: String="",//当前URL
var mDurChapterPos:Int=0,//当前章节位置
var mDurChapterCount:Int=0//当前章节内容总数
)
@@ -0,0 +1,8 @@
package io.legado.app.model.recyclerView
data class ReaderLoading(
val mChapterPagePos: Int = 0,
val mMessage: String? = null,
val mNextChapterIndex: Int = 0,
var mLoading: Boolean = false,
)
@@ -19,6 +19,7 @@ import io.legado.app.help.config.AppConfig
import io.legado.app.help.config.LocalConfig
import io.legado.app.lib.dialogs.alert
import io.legado.app.lib.theme.primaryTextColor
import io.legado.app.ui.book.manga.ReadMangaActivity
import io.legado.app.ui.book.read.ReadBookActivity
import io.legado.app.ui.book.search.SearchActivity
import io.legado.app.utils.applyNavigationBarPadding
@@ -26,7 +27,7 @@ import io.legado.app.utils.applyTint
import io.legado.app.utils.cnCompare
import io.legado.app.utils.getInt
import io.legado.app.utils.putInt
import io.legado.app.utils.startActivity
import io.legado.app.utils.startReadOrMangaActivity
import io.legado.app.utils.viewbindingdelegate.viewBinding
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.launch
@@ -76,16 +77,19 @@ class ReadRecordActivity : BaseActivity<ActivityReadRecordBinding>() {
item.isChecked = true
initData()
}
R.id.menu_sort_read_long -> {
sortMode = 1
item.isChecked = true
initData()
}
R.id.menu_sort_read_time -> {
sortMode = 2
item.isChecked = true
initData()
}
R.id.menu_enable_record -> {
AppConfig.enableReadRecord = !item.isChecked
}
@@ -165,7 +169,7 @@ class ReadRecordActivity : BaseActivity<ActivityReadRecordBinding>() {
holder: ItemViewHolder,
binding: ItemReadRecordBinding,
item: ReadRecordShow,
payloads: MutableList<Any>
payloads: MutableList<Any>,
) {
binding.apply {
tvBookName.text = item.bookName
@@ -189,7 +193,7 @@ class ReadRecordActivity : BaseActivity<ActivityReadRecordBinding>() {
if (book == null) {
SearchActivity.start(this@ReadRecordActivity, item.bookName)
} else {
startActivity<ReadBookActivity> {
startReadOrMangaActivity<ReadBookActivity, ReadMangaActivity>(book) {
putExtra("bookUrl", book.bookUrl)
}
}
@@ -15,6 +15,7 @@ import io.legado.app.help.config.AppConfig
import io.legado.app.lib.dialogs.alert
import io.legado.app.lib.permission.Permissions
import io.legado.app.lib.permission.PermissionsCompat
import io.legado.app.ui.book.manga.ReadMangaActivity
import io.legado.app.ui.book.read.ReadBookActivity
import io.legado.app.ui.file.HandleFileContract
import io.legado.app.utils.FileUtils
@@ -26,6 +27,7 @@ import io.legado.app.utils.isContentScheme
import io.legado.app.utils.readUri
import io.legado.app.utils.showDialogFragment
import io.legado.app.utils.startActivity
import io.legado.app.utils.startReadOrMangaActivity
import io.legado.app.utils.toastOnUi
import io.legado.app.utils.viewbindingdelegate.viewBinding
import kotlinx.coroutines.Dispatchers.IO
@@ -90,8 +92,8 @@ class FileAssociationActivity :
}
viewModel.openBookLiveData.observe(this) {
binding.rotateLoading.gone()
startActivity<ReadBookActivity> {
putExtra("bookUrl", it)
startReadOrMangaActivity<ReadBookActivity, ReadMangaActivity>(it) {
putExtra("bookUrl", it.bookUrl)
}
finish()
}
@@ -6,13 +6,14 @@ import androidx.lifecycle.MutableLiveData
import io.legado.app.constant.AppLog
import io.legado.app.constant.AppPattern
import io.legado.app.constant.AppPattern.bookFileRegex
import io.legado.app.data.entities.Book
import io.legado.app.model.localBook.LocalBook
import io.legado.app.utils.*
class FileAssociationViewModel(application: Application) : BaseAssociationViewModel(application) {
val importBookLiveData = MutableLiveData<Uri>()
val onLineImportLive = MutableLiveData<Uri>()
val openBookLiveData = MutableLiveData<String>()
val openBookLiveData = MutableLiveData<Book>()
val notSupportedLiveData = MutableLiveData<Pair<Uri, String>>()
fun dispatchIntent(uri: Uri) {
@@ -60,6 +61,6 @@ class FileAssociationViewModel(application: Application) : BaseAssociationViewMo
fun importBook(uri: Uri) {
val book = LocalBook.importFile(uri)
openBookLiveData.postValue(book.bookUrl)
openBookLiveData.postValue(book)
}
}
@@ -31,6 +31,7 @@ import io.legado.app.model.BookCover
import io.legado.app.service.AudioPlayService
import io.legado.app.ui.about.AppLogDialog
import io.legado.app.ui.book.changesource.ChangeBookSourceDialog
import io.legado.app.ui.book.manga.ReadMangaActivity
import io.legado.app.ui.book.read.ReadBookActivity
import io.legado.app.ui.book.source.edit.BookSourceEditActivity
import io.legado.app.ui.book.toc.TocActivityResult
@@ -45,6 +46,7 @@ import io.legado.app.utils.observeEventSticky
import io.legado.app.utils.sendToClip
import io.legado.app.utils.showDialogFragment
import io.legado.app.utils.startActivity
import io.legado.app.utils.startReadOrMangaActivity
import io.legado.app.utils.viewbindingdelegate.viewBinding
import io.legado.app.utils.visible
import kotlinx.coroutines.Dispatchers.IO
@@ -218,7 +220,7 @@ class AudioPlayActivity :
AudioPlay.book?.delete()
appDb.bookDao.insert(book)
}
startActivity<ReadBookActivity> {
startReadOrMangaActivity<ReadBookActivity,ReadMangaActivity>(book) {
putExtra("bookUrl", book.bookUrl)
}
finish()
@@ -2,19 +2,20 @@ package io.legado.app.ui.book.import
import android.os.Bundle
import android.view.MotionEvent
import android.widget.EditText
import androidx.appcompat.widget.SearchView
import androidx.lifecycle.ViewModel
import io.legado.app.R
import io.legado.app.base.VMBaseActivity
import io.legado.app.constant.AppPattern
import io.legado.app.data.appDb
import io.legado.app.data.entities.Book
import io.legado.app.databinding.ActivityImportBookBinding
import io.legado.app.help.config.AppConfig
import io.legado.app.lib.dialogs.alert
import io.legado.app.lib.dialogs.selector
import io.legado.app.lib.theme.primaryTextColor
import io.legado.app.model.localBook.LocalBook
import io.legado.app.ui.book.manga.ReadMangaActivity
import io.legado.app.ui.book.read.ReadBookActivity
import io.legado.app.ui.file.HandleFileContract
import io.legado.app.utils.ArchiveUtils
@@ -22,7 +23,7 @@ import io.legado.app.utils.FileDoc
import io.legado.app.utils.applyTint
import io.legado.app.utils.hideSoftInput
import io.legado.app.utils.shouldHideSoftInput
import io.legado.app.utils.startActivity
import io.legado.app.utils.startReadOrMangaActivity
import io.legado.app.utils.toastOnUi
import io.legado.app.utils.viewbindingdelegate.viewBinding
import kotlin.coroutines.resume
@@ -97,9 +98,9 @@ abstract class BaseImportBookActivity<VM : ViewModel> : VMBaseActivity<ActivityI
abstract fun onSearchTextChange(newText: String?)
protected fun startReadBook(bookUrl: String) {
startActivity<ReadBookActivity> {
putExtra("bookUrl", bookUrl)
protected fun startReadBook(book: Book) {
startReadOrMangaActivity<ReadBookActivity, ReadMangaActivity>(book) {
putExtra("bookUrl", book.bookUrl)
}
}
@@ -110,7 +111,7 @@ abstract class BaseImportBookActivity<VM : ViewModel> : VMBaseActivity<ActivityI
if (fileNames.size == 1) {
val name = fileNames[0]
appDb.bookDao.getBookByFileName(name)?.let {
startReadBook(it.bookUrl)
startReadBook(it)
} ?: showImportAlert(fileDoc, name)
} else {
showSelectBookReadAlert(fileDoc, fileNames)
@@ -127,7 +128,7 @@ abstract class BaseImportBookActivity<VM : ViewModel> : VMBaseActivity<ActivityI
fileNames
) { _, name, _ ->
appDb.bookDao.getBookByFileName(name)?.let {
startReadBook(it.bookUrl)
startReadBook(it)
} ?: showImportAlert(fileDoc, name)
}
}
@@ -136,12 +137,12 @@ abstract class BaseImportBookActivity<VM : ViewModel> : VMBaseActivity<ActivityI
private inline fun addArchiveToBookShelf(
fileDoc: FileDoc,
fileName: String,
onSuccess: (String) -> Unit
onSuccess: (Book) -> Unit
) {
LocalBook.importArchiveFile(fileDoc.uri, fileName) {
it.contains(fileName)
}.firstOrNull()?.run {
onSuccess.invoke(bookUrl)
onSuccess.invoke(this)
}
}
@@ -304,7 +304,7 @@ class ImportBookActivity : BaseImportBookActivity<ImportBookViewModel>(),
override fun startRead(fileDoc: FileDoc) {
if (!ArchiveUtils.isArchive(fileDoc.name)) {
appDb.bookDao.getBookByFileName(fileDoc.name)?.let {
startReadBook(it.bookUrl)
startReadBook(it)
}
} else {
onArchiveFileClick(fileDoc)
@@ -228,7 +228,7 @@ class RemoteBookActivity : BaseImportBookActivity<RemoteBookViewModel>(),
val downloadFileName = remoteBook.filename
if (!ArchiveUtils.isArchive(downloadFileName)) {
appDb.bookDao.getBookByFileName(downloadFileName)?.let {
startReadBook(it.bookUrl)
startReadBook(it)
}
} else {
AppConfig.defaultBookTreeUri ?: return
@@ -27,6 +27,7 @@ import io.legado.app.help.AppWebDav
import io.legado.app.help.book.addType
import io.legado.app.help.book.getRemoteUrl
import io.legado.app.help.book.isAudio
import io.legado.app.help.book.isImage
import io.legado.app.help.book.isLocal
import io.legado.app.help.book.isLocalTxt
import io.legado.app.help.book.isWebFile
@@ -47,6 +48,7 @@ import io.legado.app.ui.book.changecover.ChangeCoverDialog
import io.legado.app.ui.book.changesource.ChangeBookSourceDialog
import io.legado.app.ui.book.group.GroupSelectDialog
import io.legado.app.ui.book.info.edit.BookInfoEditActivity
import io.legado.app.ui.book.manga.ReadMangaActivity
import io.legado.app.ui.book.read.ReadBookActivity
import io.legado.app.ui.book.read.ReadBookActivity.Companion.RESULT_DELETED
import io.legado.app.ui.book.search.SearchActivity
@@ -310,7 +312,7 @@ class BookInfoActivity :
private fun upLoadBook(
book: Book,
bookWebDav: RemoteBookWebDav? = AppWebDav.defaultBookWebDav
bookWebDav: RemoteBookWebDav? = AppWebDav.defaultBookWebDav,
) {
lifecycleScope.launch {
waitDialog.setText("上传中.....")
@@ -604,7 +606,7 @@ class BookInfoActivity :
}
private fun showWebFileDownloadAlert(
onClick: ((Book) -> Unit)? = null
onClick: ((Book) -> Unit)? = null,
) {
val webFiles = viewModel.webFiles
if (webFiles.isEmpty()) {
@@ -653,7 +655,7 @@ class BookInfoActivity :
private fun showDecompressFileImportAlert(
archiveFileUri: Uri,
fileNames: List<String>,
success: ((Book) -> Unit)? = null
success: ((Book) -> Unit)? = null,
) {
if (fileNames.isEmpty()) {
toastOnUi(R.string.unsupport_archivefile_entry)
@@ -693,7 +695,10 @@ class BookInfoActivity :
)
else -> readBookResult.launch(
Intent(this, ReadBookActivity::class.java)
Intent(
this,
if (book.isImage&&AppConfig.showMangaUi) ReadMangaActivity::class.java else ReadBookActivity::class.java
)
.putExtra("bookUrl", book.bookUrl)
.putExtra("inBookshelf", viewModel.inBookshelf)
.putExtra("tocChanged", tocChanged)
@@ -0,0 +1,240 @@
package io.legado.app.ui.book.manga
import android.app.Application
import android.content.Intent
import io.legado.app.R
import io.legado.app.base.BaseViewModel
import io.legado.app.constant.AppLog
import io.legado.app.constant.BookType
import io.legado.app.constant.EventBus
import io.legado.app.data.appDb
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
import io.legado.app.exception.NoStackTraceException
import io.legado.app.help.book.BookHelp
import io.legado.app.help.book.isLocal
import io.legado.app.help.book.isLocalModified
import io.legado.app.help.book.removeType
import io.legado.app.help.book.simulatedTotalChapterNum
import io.legado.app.help.config.AppConfig
import io.legado.app.help.coroutine.Coroutine
import io.legado.app.model.ReadMange
import io.legado.app.model.localBook.LocalBook
import io.legado.app.model.webBook.WebBook
import io.legado.app.utils.mapParallelSafe
import io.legado.app.utils.postEvent
import io.legado.app.utils.toastOnUi
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onEmpty
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.take
class MangaViewModel(application: Application) : BaseViewModel(application) {
private var changeSourceCoroutine: Coroutine<*>? = null
/**
* 初始化
*/
fun initData(intent: Intent, success: (() -> Unit)? = null) {
execute {
ReadMange.inBookshelf = intent.getBooleanExtra("inBookshelf", true)
ReadMange.tocChanged = intent.getBooleanExtra("tocChanged", false)
val bookUrl = intent.getStringExtra("bookUrl")
val book = when {
bookUrl.isNullOrEmpty() -> appDb.bookDao.lastReadBook
else -> appDb.bookDao.getBook(bookUrl)
} ?: ReadMange.book
when {
book != null -> initMange(book)
else -> context.getString(R.string.no_book)//没有找到书
}
}.onSuccess {
success?.invoke()
}.onError {
val msg = "初始化数据失败\n${it.localizedMessage}"
AppLog.put(msg, it)
}.onFinally {
ReadMange.saveRead()
}
}
private suspend fun initMange(book: Book) {
val isSameBook = ReadMange.book?.bookUrl == book.bookUrl
if (isSameBook) {
ReadMange.upData(book)
} else {
ReadMange.resetData(book)
}
if (!book.isLocal && book.tocUrl.isEmpty() && !loadBookInfo(book)) {
return
}
if (book.isLocal && !checkLocalBookFileExist(book)) {
return
}
if ((ReadMange.durChapterPageCount == 0 || book.isLocalModified()) && !loadChapterListAwait(
book
)
) {
return
}
ensureChapterExist()
//开始加载内容
ReadMange.loadContent()
//自动换源
if (!book.isLocal && ReadMange.bookSource == null) {
autoChangeSource(book.name, book.author)
return
}
}
private suspend fun loadChapterListAwait(book: Book): Boolean {
ReadMange.bookSource?.let {
val oldBook = book.copy()
WebBook.getChapterListAwait(it, book, true).onSuccess { cList ->
if (oldBook.bookUrl == book.bookUrl) {
appDb.bookDao.update(book)
} else {
appDb.bookDao.insert(book)
BookHelp.updateCacheFolder(oldBook, book)
}
appDb.bookChapterDao.delByBook(oldBook.bookUrl)
appDb.bookChapterDao.insert(*cList.toTypedArray())
ReadMange.durChapterPageCount = cList.size
ReadMange.simulatedChapterSize = book.simulatedTotalChapterNum()
return true
}.onFailure {
//加载章节出错
ReadMange.mCallback?.loadFail("加载目录失败")
return false
}
}
return true
}
/**
* 加载详情页
*/
private suspend fun loadBookInfo(book: Book): Boolean {
val source = ReadMange.bookSource ?: return true
try {
WebBook.getBookInfoAwait(source, book, canReName = false)
return true
} catch (e: Throwable) {
// 加载详情页失败
// ReadBook.upMsg("详情页出错: ${e.localizedMessage}")
return false
}
}
private fun ensureChapterExist() {
if (ReadMange.simulatedChapterSize > 0 && ReadMange.durChapterPagePos > ReadMange.simulatedChapterSize - 1) {
ReadMange.durChapterPagePos = ReadMange.simulatedChapterSize - 1
}
}
/**
* 自动换源
*/
private fun autoChangeSource(name: String, author: String) {
if (!AppConfig.autoChangeSource) return
execute {
val sources = appDb.bookSourceDao.allTextEnabledPart
flow {
for (source in sources) {
source.getBookSource()?.let {
emit(it)
}
}
}.onStart {
// 自动换源
}.mapParallelSafe(AppConfig.threadCount) { source ->
val book = WebBook.preciseSearchAwait(this, source, name, author).getOrThrow()
if (book.tocUrl.isEmpty()) {
WebBook.getBookInfoAwait(source, book)
}
val toc = WebBook.getChapterListAwait(source, book).getOrThrow()
val chapter = toc.getOrElse(book.durChapterIndex) {
toc.last()
}
val nextChapter = toc.getOrElse(chapter.index) {
toc.first()
}
WebBook.getContentAwait(
bookSource = source,
book = book,
bookChapter = chapter,
nextChapterUrl = nextChapter.url
)
book to toc
}.take(1).onEach { (book, toc) ->
changeTo(book, toc)
}.onEmpty {
throw NoStackTraceException("没有合适书源")
}.onCompletion {
// 换源完成
}.catch {
AppLog.put("自动换源失败\n${it.localizedMessage}", it)
context.toastOnUi("自动换源失败\n${it.localizedMessage}")
}.collect()
}
}
/**
* 换源
*/
fun changeTo(book: Book, toc: List<BookChapter>) {
changeSourceCoroutine?.cancel()
changeSourceCoroutine = execute {
//换源中
ReadMange.book?.migrateTo(book, toc)
book.removeType(BookType.updateError)
ReadMange.book?.delete()
appDb.bookDao.insert(book)
appDb.bookChapterDao.insert(*toc.toTypedArray())
ReadMange.resetData(book)
toc.find { it.title.contains(ReadMange.chapterTitle) }?.run {
ReadMange.loadContent(index)
} ?: ReadMange.loadContent()
}.onError {
AppLog.put("换源失败\n$it", it, true)
}.onFinally {
postEvent(EventBus.SOURCE_CHANGED, book.bookUrl)
}
}
private fun checkLocalBookFileExist(book: Book): Boolean {
try {
LocalBook.getBookInputStream(book)
return true
} catch (e: Throwable) {
return false
}
}
fun openChapter(index: Int, durChapterPos: Int = 0) {
if (index < ReadMange.durChapterPageCount) {
ReadMange.chapterChanged = true
ReadMange.durChapterPagePos = index
ReadMange.durChapterPos = durChapterPos
ReadMange.saveRead()
ReadMange.loadContent(index)
}
}
override fun onCleared() {
super.onCleared()
changeSourceCoroutine?.cancel()
}
}
@@ -0,0 +1,483 @@
package io.legado.app.ui.book.manga
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.os.Looper
import android.view.KeyEvent
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.WindowInsets
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.core.view.WindowInsetsControllerCompat
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.recyclerview.widget.LinearLayoutManager
import com.bumptech.glide.Glide
import com.bumptech.glide.integration.recyclerview.RecyclerViewPreloader
import com.bumptech.glide.request.target.Target.SIZE_ORIGINAL
import com.bumptech.glide.util.FixedPreloadSizeProvider
import io.legado.app.BuildConfig
import io.legado.app.R
import io.legado.app.base.VMBaseActivity
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.BookProgress
import io.legado.app.data.entities.BookSource
import io.legado.app.databinding.ActivityMangeBinding
import io.legado.app.databinding.ViewLoadMoreBinding
import io.legado.app.help.book.isImage
import io.legado.app.help.config.AppConfig
import io.legado.app.help.config.ReadBookConfig
import io.legado.app.help.storage.Backup
import io.legado.app.lib.dialogs.alert
import io.legado.app.lib.theme.ThemeStore
import io.legado.app.model.ReadMange
import io.legado.app.model.ReadMange.mFirstLoading
import io.legado.app.model.recyclerView.MangeContent
import io.legado.app.model.recyclerView.ReaderLoading
import io.legado.app.receiver.NetworkChangedListener
import io.legado.app.ui.book.changesource.ChangeBookSourceDialog
import io.legado.app.ui.book.info.BookInfoActivity
import io.legado.app.ui.book.manga.rv.MangaAdapter
import io.legado.app.ui.book.read.MangaMenu
import io.legado.app.ui.book.read.ReadBookActivity.Companion.RESULT_DELETED
import io.legado.app.ui.book.toc.TocActivityResult
import io.legado.app.ui.widget.recycler.LoadMoreView
import io.legado.app.utils.ColorUtils
import io.legado.app.utils.NetworkUtils
import io.legado.app.utils.StartActivityContract
import io.legado.app.utils.getCompatColor
import io.legado.app.utils.gone
import io.legado.app.utils.immersionFullScreen
import io.legado.app.utils.printOnDebug
import io.legado.app.utils.setLightStatusBar
import io.legado.app.utils.setNavigationBarColorAuto
import io.legado.app.utils.showDialogFragment
import io.legado.app.utils.toastOnUi
import io.legado.app.utils.viewbindingdelegate.viewBinding
import io.legado.app.utils.visible
class ReadMangaActivity : VMBaseActivity<ActivityMangeBinding, MangaViewModel>(),
ReadMange.Callback, ChangeBookSourceDialog.CallBack, MangaMenu.CallBack {
private val menuLayoutIsVisible get() = binding.mangaMenu.isVisible
private val mLayoutManager by lazy {
LinearLayoutManager(this@ReadMangaActivity)
}
private val mAdapter: MangaAdapter by lazy {
MangaAdapter(this@ReadMangaActivity)
}
private val mSizeProvider by lazy {
FixedPreloadSizeProvider<Any>(
this@ReadMangaActivity.resources.displayMetrics.widthPixels,
SIZE_ORIGINAL
)
}
private val mRecyclerViewPreloader by lazy {
RecyclerViewPreloader(Glide.with(this), mAdapter, mSizeProvider, 10)
}
private val networkChangedListener by lazy {
NetworkChangedListener(this)
}
private var justInitData: Boolean = false
private var syncDialog: AlertDialog? = null
private val loadMoreView by lazy {
LoadMoreView(this).apply {
setBackgroundColor(getCompatColor(R.color.book_ant_10))
getLoading().loadingColor = getCompatColor(R.color.white)
getLoadingText().setTextColor(getCompatColor(R.color.white))
}
}
//打开目录返回选择章节返回结果
private val tocActivity =
registerForActivityResult(TocActivityResult()) {
it?.let {
binding.flLoading.isVisible = true
viewModel.openChapter(it.first, it.second)
}
}
private val bookInfoActivity =
registerForActivityResult(StartActivityContract(BookInfoActivity::class.java)) {
if (it.resultCode == RESULT_OK) {
setResult(RESULT_DELETED)
super.finish()
}
}
override val binding by viewBinding(ActivityMangeBinding::inflate)
override val viewModel by viewModels<MangaViewModel>()
override fun onActivityCreated(savedInstanceState: Bundle?) {
immersionFullScreen(WindowInsetsControllerCompat(window, binding.root))
ReadMange.register(this)
binding.mRecyclerMange.run {
adapter = mAdapter
itemAnimator = null
layoutManager = mLayoutManager
setHasFixedSize(true)
mLayoutManager.initialPrefetchItemCount = 4
mLayoutManager.isItemPrefetchEnabled = true
setItemViewCacheSize(AppConfig.preDownloadNum)
setPreScrollListener { _, _, dy, position ->
if (dy > 0 && position + 2 > mAdapter.getCurrentList().size - 3) {
if (mAdapter.getCurrentList().last() is ReaderLoading) {
val nextIndex =
(mAdapter.getCurrentList().last() as ReaderLoading).mNextChapterIndex
if (nextIndex != -1) {
scrollToBottom(false, nextIndex)
}
}
}
}
setNestedPreScrollListener { _, _, _, position ->
if (mAdapter.getCurrentList()
.isNotEmpty() && position <= mAdapter.getCurrentList().lastIndex
) {
try {
val content = mAdapter.getCurrentList()[position]
if (content is MangeContent) {
ReadMange.durChapterPos = content.mDurChapterPos.minus(1)
upText(
content.mChapterPagePos,
content.mChapterPageCount,
content.mDurChapterPos,
content.mDurChapterCount
)
}
} catch (e: Exception) {
e.printOnDebug()
}
}
}
addOnScrollListener(mRecyclerViewPreloader)
onToucheMiddle {
if (!binding.mangaMenu.isVisible) {
binding.mangaMenu.runMenuIn()
}
}
}
binding.retry.setOnClickListener {
binding.llLoading.isVisible = true
binding.llRetry.isGone = true
mFirstLoading = false
ReadMange.loadContent()
}
mAdapter.addFooterView {
ViewLoadMoreBinding.bind(loadMoreView)
}
loadMoreView.setOnClickListener {
if (!loadMoreView.isLoading && !ReadMange.gameOver) {
scrollToBottom(true, ReadMange.durChapterPagePos)
}
}
loadMoreView.gone()
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
viewModel.initData(intent)
}
private fun scrollToBottom(forceLoad: Boolean = false, index: Int) {
if ((loadMoreView.hasMore && !loadMoreView.isLoading) && !ReadMange.gameOver || forceLoad) {
loadMoreView.hasMore()
ReadMange.moveToNextChapter(index)
}
}
override fun onPostCreate(savedInstanceState: Bundle?) {
super.onPostCreate(savedInstanceState)
Looper.myQueue().addIdleHandler {
viewModel.initData(intent)
false
}
justInitData = true
}
override fun loadContentFinish(list: MutableList<Any>) {
if (!this.isDestroyed) {
mAdapter.submitList(list) {
if (!mFirstLoading) {
if (list.size > 1) {
binding.infobar.isVisible = true
upText(
ReadMange.durChapterPagePos,
ReadMange.durChapterPageCount,
ReadMange.durChapterPos,
ReadMange.durChapterCount
)
}
if (ReadMange.durChapterPos + 2 > mAdapter.getCurrentList().size - 3) {
val nextIndex =
(mAdapter.getCurrentList().last() as ReaderLoading).mNextChapterIndex
scrollToBottom(index = nextIndex)
} else {
binding.mRecyclerMange.scrollToPosition(ReadMange.durChapterPos)
}
}
if (ReadMange.chapterChanged) {
binding.mRecyclerMange.scrollToPosition(ReadMange.durChapterPos)
}
ReadMange.chapterChanged = false
loadMoreView.visible()
mFirstLoading = true
loadMoreView.stopLoad()
}
}
}
private fun upText(
chapterPagePos: Int, chapterPageCount: Int, chapterPos: Int, chapterCount: Int,
) {
binding.infobar.update(
chapterPagePos,
chapterPageCount,
chapterPagePos.minus(1f).div(chapterPageCount.minus(1f)),
chapterPos,
chapterCount
)
}
override fun onResume() {
super.onResume()
networkChangedListener.register()
networkChangedListener.onNetworkChanged = {
// 当网络是可用状态且无需初始化时同步进度(初始化中已有同步进度逻辑)
if (AppConfig.syncBookProgressPlus && NetworkUtils.isAvailable()&&!justInitData) {
ReadMange.syncProgress({ progress -> sureNewProgress(progress) })
}
}
}
override fun onPause() {
super.onPause()
if (ReadMange.inBookshelf) {
ReadMange.saveRead()
if (!BuildConfig.DEBUG) {
if (AppConfig.syncBookProgressPlus) {
ReadMange.syncProgress()
} else {
ReadMange.uploadProgress()
}
Backup.autoBack(this)
}
}
networkChangedListener.unRegister()
}
override fun loadComplete() {
binding.flLoading.isGone = true
}
override fun loadFail(msg: String) {
if (!mFirstLoading || ReadMange.chapterChanged) {
binding.llLoading.isGone = true
binding.llRetry.isVisible = true
binding.tvMsg.text = msg
} else {
loadMoreView.error(null, "加载失败,点击重试")
}
}
override fun noData() {
loadMoreView.noMore("暂无章节了!")
}
override fun adjustmentProgress() {
if (ReadMange.chapterChanged) {
binding.mRecyclerMange.scrollToPosition(ReadMange.durChapterPos)
binding.flLoading.isGone = true
}
}
override val chapterList: MutableList<Any>
get() = mAdapter.getCurrentList()
override fun onDestroy() {
ReadMange.unregister()
super.onDestroy()
}
override fun onLowMemory() {
super.onLowMemory()
Glide.get(this).clearMemory()
}
override fun sureNewProgress(progress: BookProgress) {
syncDialog?.dismiss()
syncDialog = alert(R.string.get_book_progress) {
setMessage(R.string.cloud_progress_exceeds_current)
okButton {
binding.flLoading.isVisible = true
ReadMange.setProgress(progress)
}
noButton()
}
}
override val oldBook: Book?
get() = ReadMange.book
override fun changeTo(source: BookSource, book: Book, toc: List<BookChapter>) {
if (book.isImage) {
binding.flLoading.isVisible = true
ReadMange.chapterChanged = true
viewModel.changeTo(book, toc)
} else {
toastOnUi("所选择的源不是漫画源")
}
}
override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.book_manga, menu)
return super.onCompatCreateOptionsMenu(menu)
}
/**
* 菜单
*/
override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.menu_change_source -> {
binding.mangaMenu.runMenuOut()
ReadMange.book?.let {
showDialogFragment(ChangeBookSourceDialog(it.name, it.author))
}
}
R.id.menu_catalog -> {
ReadMange.book?.let {
tocActivity.launch(it.bookUrl)
}
}
}
return super.onCompatOptionsItemSelected(item)
}
override fun upNavigationBarColor() {
when {
binding.mangaMenu.isVisible -> super.upNavigationBarColor()
!AppConfig.immNavigationBar -> super.upNavigationBarColor()
else -> setNavigationBarColorAuto(ReadBookConfig.bgMeanColor)
}
}
@SuppressLint("RtlHardcoded")
private fun upNavigationBar(value: Boolean) {
binding.mangaMenu.isVisible = value
}
override fun openBookInfoActivity() {
ReadMange.book?.let {
bookInfoActivity.launch {
putExtra("name", it.name)
putExtra("author", it.author)
}
}
}
override fun upSystemUiVisibility(value: Boolean) {
upSystemUiVisibility(isInMultiWindow, !menuLayoutIsVisible, false)
upNavigationBarColor()
upNavigationBar(value)
if (!value) {
immersionFullScreen(WindowInsetsControllerCompat(window, binding.root))
}
}
/**
* 更新状态栏,导航栏
*/
fun upSystemUiVisibility(
isInMultiWindow: Boolean,
toolBarHide: Boolean = true,
useBgMeanColor: Boolean = false,
) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window.insetsController?.run {
if (toolBarHide && ReadBookConfig.hideNavigationBar) {
hide(WindowInsets.Type.navigationBars())
} else {
show(WindowInsets.Type.navigationBars())
}
if (toolBarHide && ReadBookConfig.hideStatusBar) {
hide(WindowInsets.Type.statusBars())
} else {
show(WindowInsets.Type.statusBars())
}
}
}
upSystemUiVisibilityO(isInMultiWindow, toolBarHide)
if (toolBarHide) {
setLightStatusBar(ReadBookConfig.durConfig.curStatusIconDark())
} else {
val statusBarColor =
if (AppConfig.readBarStyleFollowPage
&& ReadBookConfig.durConfig.curBgType() == 0
|| useBgMeanColor
) {
ReadBookConfig.bgMeanColor
} else {
ThemeStore.statusBarColor(this, AppConfig.isTransparentStatusBar)
}
setLightStatusBar(ColorUtils.isColorLight(statusBarColor))
}
}
@Suppress("DEPRECATION")
private fun upSystemUiVisibilityO(
isInMultiWindow: Boolean,
toolBarHide: Boolean = true,
) {
var flag = (View.SYSTEM_UI_FLAG_LAYOUT_STABLE
or View.SYSTEM_UI_FLAG_IMMERSIVE
or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY)
if (!isInMultiWindow) {
flag = flag or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
}
if (ReadBookConfig.hideNavigationBar) {
flag = flag or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
if (toolBarHide) {
flag = flag or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
}
}
if (ReadBookConfig.hideStatusBar && toolBarHide) {
flag = flag or View.SYSTEM_UI_FLAG_FULLSCREEN
}
window.decorView.systemUiVisibility = flag
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
val keyCode = event.keyCode
val action = event.action
val isDown = action == 0
if (keyCode == KeyEvent.KEYCODE_MENU) {
if (isDown && !binding.mangaMenu.canShowMenu) {
binding.mangaMenu.runMenuIn()
return true
}
if (!isDown && !binding.mangaMenu.canShowMenu) {
binding.mangaMenu.canShowMenu = true
return true
}
}
return super.dispatchKeyEvent(event)
}
}
@@ -0,0 +1,64 @@
package io.legado.app.ui.book.manga.rv
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.ViewConfiguration
import kotlin.math.abs
open class GestureDetectorWithLongTap(
context: Context,
listener: Listener,
) : GestureDetector(context, listener) {
private val handler = Handler(Looper.getMainLooper())
private val slop = ViewConfiguration.get(context).scaledTouchSlop
private val longTapTime = ViewConfiguration.getLongPressTimeout().toLong()
private val doubleTapTime = ViewConfiguration.getDoubleTapTimeout().toLong()
private var downX = 0f
private var downY = 0f
private var lastUp = 0L
private var lastDownEvent: MotionEvent? = null
private val longTapFn = Runnable { listener.onLongTapConfirmed(lastDownEvent!!) }
override fun onTouchEvent(ev: MotionEvent): Boolean {
when (ev.actionMasked) {
MotionEvent.ACTION_DOWN -> {
lastDownEvent?.recycle()
lastDownEvent = MotionEvent.obtain(ev)
if (ev.downTime - lastUp > doubleTapTime) {
downX = ev.rawX
downY = ev.rawY
handler.postDelayed(longTapFn, longTapTime)
}
}
MotionEvent.ACTION_MOVE -> {
if (abs(ev.rawX - downX) > slop || abs(ev.rawY - downY) > slop) {
handler.removeCallbacks(longTapFn)
}
}
MotionEvent.ACTION_UP -> {
lastUp = ev.eventTime
handler.removeCallbacks(longTapFn)
}
MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_POINTER_DOWN -> {
handler.removeCallbacks(longTapFn)
}
}
return super.onTouchEvent(ev)
}
open class Listener : SimpleOnGestureListener() {
open fun onLongTapConfirmed(ev: MotionEvent) {
}
}
}
@@ -0,0 +1,230 @@
package io.legado.app.ui.book.manga.rv
import android.content.Context
import android.util.SparseArray
import android.view.LayoutInflater
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import androidx.annotation.IntRange
import androidx.core.view.updateLayoutParams
import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import com.bumptech.glide.Glide
import com.bumptech.glide.ListPreloader.PreloadModelProvider
import com.bumptech.glide.RequestBuilder
import com.github.panpf.zoomimage.zoom.GestureType
import io.legado.app.R
import io.legado.app.base.adapter.ItemViewHolder
import io.legado.app.base.adapter.RecyclerAdapter.Companion.TYPE_FOOTER_VIEW
import io.legado.app.databinding.BookComicLoadingRvBinding
import io.legado.app.databinding.BookComicRvBinding
import io.legado.app.help.glide.progress.ProgressManager
import io.legado.app.model.BookCover
import io.legado.app.model.ReadMange
import io.legado.app.model.recyclerView.MangaVH
import io.legado.app.model.recyclerView.MangeContent
import io.legado.app.model.recyclerView.ReaderLoading
import io.legado.app.utils.getCompatDrawable
import java.util.Collections
class MangaAdapter(private val context: Context) :
RecyclerView.Adapter<RecyclerView.ViewHolder>(), PreloadModelProvider<Any> {
companion object {
private const val LOADING_VIEW = 0
private const val CONTENT_VIEW = 1
}
private val mDiffCallback: DiffUtil.ItemCallback<Any> = object : DiffUtil.ItemCallback<Any>() {
override fun areItemsTheSame(oldItem: Any, newItem: Any): Boolean {
return if (oldItem is ReaderLoading && newItem is ReaderLoading) {
newItem.mMessage == oldItem.mMessage
} else if (oldItem is MangeContent && newItem is MangeContent) {
oldItem.mImageUrl == newItem.mImageUrl
} else false
}
override fun areContentsTheSame(oldItem: Any, newItem: Any): Boolean {
return if (oldItem is ReaderLoading && newItem is ReaderLoading) {
oldItem == newItem
} else if (oldItem is MangeContent && newItem is MangeContent) {
oldItem == newItem
} else false
}
}
private val mDiffer = AsyncListDiffer(this, mDiffCallback)
private fun getItem(@IntRange(from = 0) position: Int) = mDiffer.currentList[position]
fun getCurrentList() = mDiffer.currentList
//全部替换数据
fun submitList(contents: MutableList<Any>, runnable: Runnable) {
val currentList = mDiffer.currentList.toMutableList()
currentList.addAll(contents)
if (ReadMange.chapterChanged) {
mDiffer.submitList(contents) {
runnable.run()
}
} else {
mDiffer.submitList(currentList) {
runnable.run()
}
}
}
inner class PageViewHolder(binding: BookComicRvBinding) :
MangaVH<BookComicRvBinding>(binding, context) {
init {
initComponent(
binding.loading,
binding.image,
binding.progress,
binding.retry,
binding.flProgress
)
binding.image.zoomable.disabledGestureTypesState.value =
GestureType.DOUBLE_TAP_SCALE or GestureType.ONE_FINGER_SCALE or
GestureType.TWO_FINGER_SCALE or GestureType.KEYBOARD_DRAG or
GestureType.ONE_FINGER_DRAG or GestureType.KEYBOARD_SCALE or GestureType.MOUSE_WHEEL_SCALE
binding.retry.setOnClickListener {
val item = mDiffer.currentList[layoutPosition]
if (item is MangeContent) {
loadImageWithRetry(item.mImageUrl)
}
}
}
fun onBind(item: MangeContent) {
loadImageWithRetry(item.mImageUrl)
}
}
inner class PageMoreViewHolder(val binding: BookComicLoadingRvBinding) :
RecyclerView.ViewHolder(binding.root) {
fun onBind(item: ReaderLoading) {
val message = item.mMessage
binding.text.text = message
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when {
viewType >= TYPE_FOOTER_VIEW -> {
ItemViewHolder(footerItems.get(viewType).invoke(parent))
}
viewType == LOADING_VIEW -> PageMoreViewHolder(
BookComicLoadingRvBinding.inflate(
LayoutInflater.from(
parent.context
), parent, false
)
)
viewType == CONTENT_VIEW -> PageViewHolder(
BookComicRvBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
else -> error("Unknown view type!")
}
}
override fun getItemCount(): Int = getActualItemCount() + getFooterCount()
override fun getItemViewType(position: Int): Int {
return when {
isFooter(position) -> TYPE_FOOTER_VIEW + position - getActualItemCount()
getItem(position) is MangeContent -> CONTENT_VIEW
getItem(position) is ReaderLoading -> LOADING_VIEW
else -> error("Unknown view type!")
}
}
fun getFooterCount() = footerItems.size()
private fun isFooter(position: Int) = position >= getActualItemCount()
override fun onViewRecycled(vh: RecyclerView.ViewHolder) {
super.onViewRecycled(vh)
when (vh) {
is PageViewHolder -> {
vh.itemView.updateLayoutParams<ViewGroup.LayoutParams> {
height = MATCH_PARENT
}
Glide.with(context).clear(vh.binding.image)
if (vh.binding.image.tag is String) {
ProgressManager.removeListener(vh.binding.image.tag as String)
}
}
}
}
override fun onBindViewHolder(vh: RecyclerView.ViewHolder, position: Int) {
when (vh) {
is PageViewHolder -> vh.onBind(getItem(position) as MangeContent)
is PageMoreViewHolder -> vh.onBind(getItem(position) as ReaderLoading)
}
}
private val footerItems: SparseArray<(parent: ViewGroup) -> ViewBinding> by lazy { SparseArray() }
@Synchronized
fun addFooterView(footer: ((parent: ViewGroup) -> ViewBinding)) {
kotlin.runCatching {
val index = getActualItemCount() + footerItems.size()
footerItems.put(TYPE_FOOTER_VIEW + footerItems.size(), footer)
notifyItemInserted(index)
}
}
/**
* 除去header和footer
*/
fun getActualItemCount() = getCurrentList().size
@Synchronized
fun removeFooterView(footer: ((parent: ViewGroup) -> ViewBinding)) {
kotlin.runCatching {
val index = footerItems.indexOfValue(footer)
if (index >= 0) {
footerItems.remove(index)
notifyItemRemoved(getActualItemCount() + index - 2)
}
}
}
override fun getPreloadItems(position: Int): MutableList<Any> {
if (getCurrentList().isEmpty()) return Collections.emptyList()
if (position >= getCurrentList().size) return Collections.emptyList()
return getCurrentList().subList(position, position + 1)
}
override fun getPreloadRequestBuilder(item: Any): RequestBuilder<*>? {
if (item is MangeContent) {
return BookCover.loadManga(
context,
item.mImageUrl,
sourceOrigin = ReadMange.book?.origin,
manga = true,
useDefaultCover = context.getCompatDrawable(R.color.book_ant_10)
)
}
return null
}
}
@@ -0,0 +1,85 @@
package io.legado.app.ui.book.manga.rv
import android.content.Context
import android.graphics.Rect
import android.util.AttributeSet
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.ScaleGestureDetector
import android.widget.FrameLayout
import io.legado.app.help.config.AppConfig
class WebtoonFrame : FrameLayout {
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet, defStyle: Int) : super(
context,
attrs,
defStyle
)
private val scaleDetector = ScaleGestureDetector(context, ScaleListener())
private val flingDetector = GestureDetector(context, FlingListener())
var doubleTapZoom = true
set(value) {
field = value
recycler?.doubleTapZoom = value
scaleDetector.isQuickScaleEnabled = value
}
private val recycler: WebtoonRecyclerView?
get() = getChildAt(0) as? WebtoonRecyclerView
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
if (!AppConfig.disableMangaScaling) {
scaleDetector.onTouchEvent(ev)
flingDetector.onTouchEvent(ev)
val recyclerRect = Rect()
recycler?.getHitRect(recyclerRect) ?: return super.dispatchTouchEvent(ev)
recyclerRect.inset(1, 1)
if (recyclerRect.right < recyclerRect.left || recyclerRect.bottom < recyclerRect.top) {
return super.dispatchTouchEvent(ev)
}
ev.setLocation(
ev.x.coerceIn(recyclerRect.left.toFloat(), recyclerRect.right.toFloat()),
ev.y.coerceIn(recyclerRect.top.toFloat(), recyclerRect.bottom.toFloat()),
)
}
return super.dispatchTouchEvent(ev)
}
inner class ScaleListener : ScaleGestureDetector.SimpleOnScaleGestureListener() {
override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
recycler?.onScaleBegin()
return true
}
override fun onScale(detector: ScaleGestureDetector): Boolean {
recycler?.onScale(detector.scaleFactor)
return true
}
override fun onScaleEnd(detector: ScaleGestureDetector) {
recycler?.onScaleEnd()
}
}
inner class FlingListener : GestureDetector.SimpleOnGestureListener() {
override fun onDown(e: MotionEvent): Boolean {
return true
}
override fun onFling(
e1: MotionEvent?,
e2: MotionEvent,
velocityX: Float,
velocityY: Float,
): Boolean {
return recycler?.zoomFling(velocityX.toInt(), velocityY.toInt()) ?: false
}
}
}
@@ -0,0 +1,390 @@
package io.legado.app.ui.book.manga.rv
import android.animation.AnimatorSet
import android.animation.ValueAnimator
import android.content.Context
import android.graphics.RectF
import android.util.AttributeSet
import android.view.HapticFeedbackConstants
import android.view.MotionEvent
import android.view.ViewConfiguration
import android.view.animation.DecelerateInterpolator
import androidx.core.animation.doOnEnd
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import io.legado.app.help.config.AppConfig
import io.legado.app.utils.findCenterViewPosition
import kotlin.math.abs
class WebtoonRecyclerView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyle: Int = 0,
) : RecyclerView(context, attrs, defStyle) {
private var isZooming = false
private var atLastPosition = false
private var atFirstPosition = false
private var halfWidth = 0
private var halfHeight = 0
private var originalHeight = 0
private var heightSet = false
private var firstVisibleItemPosition = 0
private var lastVisibleItemPosition = 0
private var currentScale = DEFAULT_RATE
private var mLastCenterViewPosition = 0
private var mPreScrollListener: IComicPreScroll? = null
private var mNestedPreScrollListener: IComicPreScroll? = null
private val listener = GestureListener()
private val detector = Detector()
private val mcRect = RectF()
private var mToucheMiddle: (() -> Unit)? = null
//起始点
private var startX: Float = 0f
private var startY: Float = 0f
var doubleTapZoom = true
var tapListener: ((MotionEvent) -> Unit)? = null
var longTapListener: ((MotionEvent) -> Boolean)? = null
fun onToucheMiddle(init: () -> Unit) = apply { this.mToucheMiddle = init }
override fun onMeasure(widthSpec: Int, heightSpec: Int) {
halfWidth = MeasureSpec.getSize(widthSpec) / 2
halfHeight = MeasureSpec.getSize(heightSpec) / 2
if (!heightSet) {
originalHeight = MeasureSpec.getSize(heightSpec)
heightSet = true
}
super.onMeasure(widthSpec, heightSpec)
}
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
detector.onTouchEvent(ev!!)
return super.dispatchTouchEvent(ev)
}
override fun onScrolled(dx: Int, dy: Int) {
super.onScrolled(dx, dy)
val layoutManager = layoutManager
lastVisibleItemPosition =
(layoutManager as LinearLayoutManager).findLastVisibleItemPosition()
firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()
}
override fun onScrollStateChanged(state: Int) {
super.onScrollStateChanged(state)
val layoutManager = layoutManager
val visibleItemCount = layoutManager?.childCount ?: 0
val totalItemCount = layoutManager?.itemCount ?: 0
atLastPosition = visibleItemCount > 0 && lastVisibleItemPosition == totalItemCount - 1
atFirstPosition = firstVisibleItemPosition == 0
}
override fun dispatchNestedPreScroll(
dx: Int,
dy: Int,
consumed: IntArray?,
offsetInWindow: IntArray?,
type: Int
): Boolean {
val position = findCenterViewPosition()
if (position != NO_POSITION && position != mLastCenterViewPosition) {
mLastCenterViewPosition = position
mPreScrollListener?.onPreScrollListener(this, dx, dy, position)
}
mNestedPreScrollListener?.onPreScrollListener(this, dx, dy, position)
return super.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, type)
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
mcRect.set(width * 0.33f, height * 0.33f, width * 0.66f, height * 0.66f)
}
private fun getPositionX(positionX: Float): Float {
if (currentScale < 1) {
return 0f
}
val maxPositionX = halfWidth * (currentScale - 1)
return positionX.coerceIn(-maxPositionX, maxPositionX)
}
private fun getPositionY(positionY: Float): Float {
if (currentScale < 1) {
return (originalHeight / 2 - halfHeight).toFloat()
}
val maxPositionY = halfHeight * (currentScale - 1)
return positionY.coerceIn(-maxPositionY, maxPositionY)
}
private fun zoom(
fromRate: Float,
toRate: Float,
fromX: Float,
toX: Float,
fromY: Float,
toY: Float,
) {
isZooming = true
val animatorSet = AnimatorSet()
val translationXAnimator = ValueAnimator.ofFloat(fromX, toX)
translationXAnimator.addUpdateListener { animation -> x = animation.animatedValue as Float }
val translationYAnimator = ValueAnimator.ofFloat(fromY, toY)
translationYAnimator.addUpdateListener { animation -> y = animation.animatedValue as Float }
val scaleAnimator = ValueAnimator.ofFloat(fromRate, toRate)
scaleAnimator.addUpdateListener { animation ->
currentScale = animation.animatedValue as Float
setScaleRate(currentScale)
}
animatorSet.playTogether(translationXAnimator, translationYAnimator, scaleAnimator)
animatorSet.duration = ANIMATOR_DURATION_TIME.toLong()
animatorSet.interpolator = DecelerateInterpolator()
animatorSet.start()
animatorSet.doOnEnd {
isZooming = false
currentScale = toRate
}
}
fun zoomFling(velocityX: Int, velocityY: Int): Boolean {
if (currentScale <= 1f) return false
val distanceTimeFactor = 0.4f
val animatorSet = AnimatorSet()
if (velocityX != 0) {
val dx = (distanceTimeFactor * velocityX / 2)
val newX = getPositionX(x + dx)
val translationXAnimator = ValueAnimator.ofFloat(x, newX)
translationXAnimator.addUpdateListener { animation ->
x = getPositionX(animation.animatedValue as Float)
}
animatorSet.play(translationXAnimator)
}
if (velocityY != 0 && (atFirstPosition || atLastPosition)) {
val dy = (distanceTimeFactor * velocityY / 2)
val newY = getPositionY(y + dy)
val translationYAnimator = ValueAnimator.ofFloat(y, newY)
translationYAnimator.addUpdateListener { animation ->
y = getPositionY(animation.animatedValue as Float)
}
animatorSet.play(translationYAnimator)
}
animatorSet.duration = 400
animatorSet.interpolator = DecelerateInterpolator()
animatorSet.start()
return true
}
private fun zoomScrollBy(dx: Int, dy: Int) {
if (dx != 0) {
x = getPositionX(x + dx)
}
if (dy != 0) {
y = getPositionY(y + dy)
}
}
private fun setScaleRate(rate: Float) {
scaleX = rate
scaleY = rate
}
fun onScale(scaleFactor: Float) {
currentScale *= scaleFactor
currentScale = currentScale.coerceIn(
MIN_RATE,
MAX_SCALE_RATE,
)
setScaleRate(currentScale)
layoutParams.height = if (currentScale < 1) {
(originalHeight / currentScale).toInt()
} else {
originalHeight
}
halfHeight = layoutParams.height / 2
if (currentScale != DEFAULT_RATE) {
x = getPositionX(x)
y = getPositionY(y)
} else {
x = 0f
y = 0f
}
requestLayout()
}
fun onScaleBegin() {
if (detector.isDoubleTapping) {
detector.isQuickScaling = true
}
}
fun onScaleEnd() {
if (scaleX < MIN_RATE) {
zoom(currentScale, MIN_RATE, x, 0f, y, 0f)
}
}
inner class GestureListener : GestureDetectorWithLongTap.Listener() {
override fun onSingleTapConfirmed(ev: MotionEvent): Boolean {
if (mcRect.contains(startX, startY)) {
mToucheMiddle?.invoke()
} else {
tapListener?.invoke(ev)
}
return false
}
override fun onDoubleTap(ev: MotionEvent): Boolean {
detector.isDoubleTapping = true
return false
}
fun onDoubleTapConfirmed(ev: MotionEvent) {
if (!isZooming && doubleTapZoom) {
if (scaleX != DEFAULT_RATE) {
zoom(currentScale, DEFAULT_RATE, x, 0f, y, 0f)
} else {
val toScale = 2f
val toX = (halfWidth - ev.x) * (toScale - 1)
val toY = (halfHeight - ev.y) * (toScale - 1)
zoom(DEFAULT_RATE, toScale, 0f, toX, 0f, toY)
}
}
}
override fun onLongTapConfirmed(ev: MotionEvent) {
if (longTapListener?.invoke(ev) == true) {
performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
}
}
}
inner class Detector : GestureDetectorWithLongTap(context, listener) {
private var scrollPointerId = 0
private var downX = 0
private var downY = 0
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
private var isZoomDragging = false
var isDoubleTapping = false
var isQuickScaling = false
val disableMangaScaling = AppConfig.disableMangaScaling
override fun onTouchEvent(ev: MotionEvent): Boolean {
val action = ev.actionMasked
val actionIndex = ev.actionIndex
when (action) {
MotionEvent.ACTION_DOWN -> {
scrollPointerId = ev.getPointerId(0)
val eveX = (ev.x + 0.5f)
val eveY = (ev.y + 0.5f)
downX = eveX.toInt()
downY = eveY.toInt()
startX = eveX
startY = eveY
}
MotionEvent.ACTION_POINTER_DOWN -> {
scrollPointerId = ev.getPointerId(actionIndex)
downX = (ev.getX(actionIndex) + 0.5f).toInt()
downY = (ev.getY(actionIndex) + 0.5f).toInt()
}
MotionEvent.ACTION_MOVE -> {
if (disableMangaScaling) {
return false
}
if (isDoubleTapping && isQuickScaling) {
return true
}
val index = ev.findPointerIndex(scrollPointerId)
if (index < 0) {
return false
}
val x = (ev.getX(index) + 0.5f).toInt()
val y = (ev.getY(index) + 0.5f).toInt()
var dx = x - downX
var dy = if (atFirstPosition || atLastPosition) y - downY else 0
if (!isZoomDragging && currentScale > 1f) {
var startScroll = false
if (abs(dx) > touchSlop) {
if (dx < 0) {
dx += touchSlop
} else {
dx -= touchSlop
}
startScroll = true
}
if (abs(dy) > touchSlop) {
if (dy < 0) {
dy += touchSlop
} else {
dy -= touchSlop
}
startScroll = true
}
if (startScroll) {
isZoomDragging = true
}
}
if (isZoomDragging) {
zoomScrollBy(dx, dy)
}
}
MotionEvent.ACTION_UP -> {
if (isDoubleTapping && !isQuickScaling && !disableMangaScaling) {
listener.onDoubleTapConfirmed(ev)
}
isZoomDragging = false
isDoubleTapping = false
isQuickScaling = false
}
MotionEvent.ACTION_CANCEL -> {
isZoomDragging = false
isDoubleTapping = false
isQuickScaling = false
}
}
return super.onTouchEvent(ev)
}
}
fun setPreScrollListener(iComicPreScroll: IComicPreScroll) {
mPreScrollListener = iComicPreScroll
}
fun setNestedPreScrollListener(iComicPreScroll: IComicPreScroll) {
mNestedPreScrollListener = iComicPreScroll
}
fun interface IComicPreScroll {
fun onPreScrollListener(recyclerView: RecyclerView, dx: Int, dy: Int, position: Int)
}
}
private const val ANIMATOR_DURATION_TIME = 200
private const val MIN_RATE = 0.5f
private const val DEFAULT_RATE = 1f
private const val MAX_SCALE_RATE = 3f
@@ -0,0 +1,236 @@
package io.legado.app.ui.book.read
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View.OnClickListener
import android.view.View.OnLongClickListener
import android.view.animation.Animation
import android.widget.FrameLayout
import androidx.core.view.isGone
import androidx.core.view.isVisible
import io.legado.app.R
import io.legado.app.databinding.ViewMangaMenuBinding
import io.legado.app.help.IntentData
import io.legado.app.help.config.AppConfig
import io.legado.app.help.config.ReadBookConfig
import io.legado.app.lib.dialogs.alert
import io.legado.app.lib.theme.bottomBackground
import io.legado.app.lib.theme.getPrimaryTextColor
import io.legado.app.lib.theme.primaryColor
import io.legado.app.lib.theme.primaryTextColor
import io.legado.app.model.ReadMange
import io.legado.app.ui.browser.WebViewActivity
import io.legado.app.utils.ColorUtils
import io.legado.app.utils.ConstraintModify
import io.legado.app.utils.activity
import io.legado.app.utils.applyNavigationBarPadding
import io.legado.app.utils.dpToPx
import io.legado.app.utils.gone
import io.legado.app.utils.invisible
import io.legado.app.utils.loadAnimation
import io.legado.app.utils.modifyBegin
import io.legado.app.utils.openUrl
import io.legado.app.utils.startActivity
import io.legado.app.utils.visible
class MangaMenu @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
) : FrameLayout(context, attrs) {
private val binding = ViewMangaMenuBinding.inflate(LayoutInflater.from(context), this, true)
private val callBack: CallBack get() = activity as CallBack
var canShowMenu: Boolean = false
private val menuTopIn: Animation by lazy {
loadAnimation(context, R.anim.anim_readbook_top_in)
}
private val menuTopOut: Animation by lazy {
loadAnimation(context, R.anim.anim_readbook_top_out)
}
private val menuBottomIn: Animation by lazy {
loadAnimation(context, R.anim.anim_readbook_bottom_in)
}
private val menuBottomOut: Animation by lazy {
loadAnimation(context, R.anim.anim_readbook_bottom_out)
}
private var isMenuOutAnimating = false
private val immersiveMenu: Boolean
get() = AppConfig.readBarStyleFollowPage && ReadBookConfig.durConfig.curBgType() == 0
private var bgColor: Int = if (immersiveMenu) {
kotlin.runCatching {
Color.parseColor(ReadBookConfig.durConfig.curBgStr())
}.getOrDefault(context.bottomBackground)
} else {
context.bottomBackground
}
private var textColor: Int = if (immersiveMenu) {
ReadBookConfig.durConfig.curTextColor()
} else {
context.getPrimaryTextColor(ColorUtils.isColorLight(bgColor))
}
private val menuOutListener = object : Animation.AnimationListener {
override fun onAnimationStart(animation: Animation) {
isMenuOutAnimating = true
binding.vwMenuBg.setOnClickListener(null)
}
override fun onAnimationEnd(animation: Animation) {
binding.titleBar.invisible()
isMenuOutAnimating = false
canShowMenu = false
callBack.upSystemUiVisibility(false)
}
override fun onAnimationRepeat(animation: Animation) = Unit
}
private val menuInListener = object : Animation.AnimationListener {
override fun onAnimationStart(animation: Animation) {
binding.tvSourceAction.text =
ReadMange.bookSource?.bookSourceName ?: context.getString(R.string.book_source)
callBack.upSystemUiVisibility(true)
binding.tvSourceAction.isGone = false
}
@SuppressLint("RtlHardcoded")
override fun onAnimationEnd(animation: Animation) {
binding.run {
vwMenuBg.setOnClickListener { runMenuOut() }
}
}
override fun onAnimationRepeat(animation: Animation) = Unit
}
init {
initView()
bindEvent()
}
private fun initView(reset: Boolean = false) = binding.run {
initAnimation()
if (immersiveMenu) {
val lightTextColor = ColorUtils.withAlpha(ColorUtils.lightenColor(textColor), 0.75f)
titleBar.setTextColor(textColor)
titleBar.setBackgroundColor(bgColor)
titleBar.setColorFilter(textColor)
tvChapterName.setTextColor(lightTextColor)
tvChapterUrl.setTextColor(lightTextColor)
} else if (reset) {
val bgColor = context.primaryColor
val textColor = context.primaryTextColor
titleBar.setTextColor(textColor)
titleBar.setBackgroundColor(bgColor)
titleBar.setColorFilter(textColor)
tvChapterName.setTextColor(textColor)
tvChapterUrl.setTextColor(textColor)
}
val brightnessBackground = GradientDrawable()
brightnessBackground.cornerRadius = 5F.dpToPx()
brightnessBackground.setColor(ColorUtils.adjustAlpha(bgColor, 0.5f))
if (AppConfig.isEInkMode) {
titleBar.setBackgroundResource(R.drawable.bg_eink_border_bottom)
}
if (AppConfig.showReadTitleBarAddition) {
titleBarAddition.visible()
} else {
titleBarAddition.gone()
}
upBrightnessVwPos()
/**
* 确保视图不被导航栏遮挡
*/
applyNavigationBarPadding()
}
private fun upBrightnessVwPos() {
if (AppConfig.brightnessVwPos) {
binding.root.modifyBegin()
.clear(R.id.ll_brightness, ConstraintModify.Anchor.LEFT)
.rightToRightOf(R.id.ll_brightness, R.id.vw_menu_root)
.commit()
} else {
binding.root.modifyBegin()
.clear(R.id.ll_brightness, ConstraintModify.Anchor.RIGHT)
.leftToLeftOf(R.id.ll_brightness, R.id.vw_menu_root)
.commit()
}
}
private fun initAnimation() {
menuTopIn.setAnimationListener(menuInListener)
menuTopOut.setAnimationListener(menuOutListener)
}
fun runMenuOut(anim: Boolean = !AppConfig.isEInkMode) {
if (isMenuOutAnimating) {
return
}
if (this.isVisible) {
if (anim) {
binding.titleBar.startAnimation(menuTopOut)
} else {
menuOutListener.onAnimationStart(menuBottomOut)
menuOutListener.onAnimationEnd(menuBottomOut)
}
}
}
fun runMenuIn(anim: Boolean = !AppConfig.isEInkMode) {
this.visible()
binding.titleBar.visible()
if (anim) {
binding.titleBar.startAnimation(menuTopIn)
} else {
menuInListener.onAnimationStart(menuBottomIn)
menuInListener.onAnimationEnd(menuBottomIn)
}
}
private fun bindEvent() = binding.run {
vwMenuBg.setOnClickListener { runMenuOut() }
titleBar.toolbar.setOnClickListener {
callBack.openBookInfoActivity()
}
val chapterViewClickListener = OnClickListener {
if (AppConfig.readUrlInBrowser) {
context.openUrl(tvChapterUrl.text.toString().substringBefore(",{"))
} else {
context.startActivity<WebViewActivity> {
val url = tvChapterUrl.text.toString()
putExtra("title", tvChapterName.text)
putExtra("url", url)
IntentData.put(url, ReadMange.bookSource?.getHeaderMap(true))
}
}
}
val chapterViewLongClickListener = OnLongClickListener {
context.alert(R.string.open_fun) {
setMessage(R.string.use_browser_open)
okButton {
AppConfig.readUrlInBrowser = true
}
noButton {
AppConfig.readUrlInBrowser = false
}
}
true
}
tvChapterName.setOnClickListener(chapterViewClickListener)
tvChapterName.setOnLongClickListener(chapterViewLongClickListener)
tvChapterUrl.setOnClickListener(chapterViewClickListener)
tvChapterUrl.setOnLongClickListener(chapterViewLongClickListener)
}
interface CallBack {
fun openBookInfoActivity()
fun upSystemUiVisibility(value:Boolean)
}
}
@@ -28,6 +28,7 @@ import io.legado.app.lib.theme.accentColor
import io.legado.app.lib.theme.primaryColor
import io.legado.app.ui.book.audio.AudioPlayActivity
import io.legado.app.ui.book.info.BookInfoActivity
import io.legado.app.ui.book.manga.ReadMangaActivity
import io.legado.app.ui.book.read.ReadBookActivity
import io.legado.app.ui.main.MainViewModel
import io.legado.app.utils.cnCompare
@@ -35,6 +36,7 @@ import io.legado.app.utils.flowWithLifecycleAndDatabaseChangeFirst
import io.legado.app.utils.observeEvent
import io.legado.app.utils.setEdgeEffectColor
import io.legado.app.utils.startActivity
import io.legado.app.utils.startReadOrMangaActivity
import io.legado.app.utils.viewbindingdelegate.viewBinding
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -247,7 +249,7 @@ class BooksFragment() : BaseFragment(R.layout.fragment_books),
putExtra("bookUrl", book.bookUrl)
}
else -> startActivity<ReadBookActivity> {
else -> startReadOrMangaActivity<ReadBookActivity, ReadMangaActivity>(book) {
putExtra("bookUrl", book.bookUrl)
}
}
@@ -23,6 +23,7 @@ import io.legado.app.lib.theme.primaryColor
import io.legado.app.ui.book.audio.AudioPlayActivity
import io.legado.app.ui.book.group.GroupEditDialog
import io.legado.app.ui.book.info.BookInfoActivity
import io.legado.app.ui.book.manga.ReadMangaActivity
import io.legado.app.ui.book.read.ReadBookActivity
import io.legado.app.ui.book.search.SearchActivity
import io.legado.app.ui.main.bookshelf.BaseBookshelfFragment
@@ -203,7 +204,7 @@ class BookshelfFragment2() : BaseBookshelfFragment(R.layout.fragment_bookshelf2)
startActivity<AudioPlayActivity> {
putExtra("bookUrl", item.bookUrl)
}
else -> startActivity<ReadBookActivity> {
else -> startReadOrMangaActivity<ReadBookActivity,ReadMangaActivity>(item) {
putExtra("bookUrl", item.bookUrl)
}
}
@@ -0,0 +1,205 @@
package io.legado.app.ui.widget
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Rect
import android.util.AttributeSet
import android.view.View
import android.view.WindowInsets
import androidx.annotation.AttrRes
import androidx.core.content.ContextCompat
import androidx.core.content.res.use
import androidx.core.graphics.ColorUtils
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import io.legado.app.R
import splitties.dimensions.dp
import java.text.DecimalFormat
import java.text.NumberFormat
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import com.google.android.material.R as materialR
class ReaderInfoBarView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
@AttrRes defStyleAttr: Int = 0,
) : View(context, attrs, defStyleAttr) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val textBounds = Rect()
private val timeFormat = SimpleDateFormat.getTimeInstance(SimpleDateFormat.SHORT)
private val timeReceiver = TimeReceiver()
private var insetLeft: Int = 0
private var insetRight: Int = 0
private var insetTop: Int = 0
private var cutoutInsetLeft = 0
private var cutoutInsetRight = 0
private val colorText = ColorUtils.setAlphaComponent(
context.obtainStyledAttributes(intArrayOf(materialR.attr.colorOnSurface)).use {
it.getColor(0, Color.BLACK)
},
200,
)
private val colorOutline = ColorUtils.setAlphaComponent(
context.obtainStyledAttributes(intArrayOf(materialR.attr.colorSurface)).use {
it.getColor(0, Color.WHITE)
},
200,
)
private var timeText = timeFormat.format(Date())
private var text: String = ""
private val innerHeight
get() = height - paddingTop - paddingBottom - insetTop
private val innerWidth
get() = width - paddingLeft - paddingRight - insetLeft - insetRight
init {
val insetStart = dp(10f).toInt()
val insetEnd = dp(10f).toInt()
paint.strokeWidth = dp(2f)
paint.setShadowLayer(2f, 1f, 1f, Color.GRAY)
insetLeft = insetStart
insetRight = insetEnd
insetTop = minOf(insetLeft, insetRight)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val ty = innerHeight / 2f + textBounds.height() / 2f - textBounds.bottom
paint.textAlign = Paint.Align.LEFT
canvas.drawTextOutline(
text,
(paddingLeft + insetLeft + cutoutInsetLeft).toFloat(),
paddingTop + insetTop + ty,
)
paint.textAlign = Paint.Align.RIGHT
canvas.drawTextOutline(
timeText,
(width - paddingRight - insetRight - cutoutInsetRight).toFloat(),
paddingTop + insetTop + ty,
)
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
updateCutoutInsets(ViewCompat.getRootWindowInsets(this))
updateTextSize()
}
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
updateCutoutInsets(WindowInsetsCompat.toWindowInsetsCompat(insets))
return super.onApplyWindowInsets(insets)
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
ContextCompat.registerReceiver(
context,
timeReceiver,
IntentFilter(Intent.ACTION_TIME_TICK),
ContextCompat.RECEIVER_EXPORTED,
)
updateCutoutInsets(ViewCompat.getRootWindowInsets(this))
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
context.unregisterReceiver(timeReceiver)
}
fun update(
currentPage: Int,
totalPage: Int,
percent: Float,
chapterIndex: Int,
chapterCount: Int
) {
text = context.getString(
R.string.book_reader_info_bar,
chapterIndex,
chapterCount,
currentPage,
totalPage,
if (percent in 0f..1f) (percent * 100).format() else ""
)
updateTextSize()
invalidate()
}
private fun Number.format(
decimals: Int = 0,
decPoint: Char = '.',
thousandsSep: Char? = ' '
): String {
val formatter = NumberFormat.getInstance(Locale.US) as DecimalFormat
val symbols = formatter.decimalFormatSymbols
if (thousandsSep != null) {
symbols.groupingSeparator = thousandsSep
formatter.isGroupingUsed = true
} else {
formatter.isGroupingUsed = false
}
symbols.decimalSeparator = decPoint
formatter.decimalFormatSymbols = symbols
formatter.minimumFractionDigits = decimals
formatter.maximumFractionDigits = decimals
return when (this) {
is Float,
is Double,
-> formatter.format(this.toDouble())
else -> formatter.format(this.toLong())
}
}
private fun updateTextSize() {
val str = text + timeText
val testTextSize = 48f
paint.textSize = testTextSize
paint.getTextBounds(str, 0, str.length, textBounds)
paint.textSize = testTextSize * innerHeight / textBounds.height()
paint.getTextBounds(str, 0, str.length, textBounds)
}
private fun Canvas.drawTextOutline(text: String, x: Float, y: Float) {
paint.color = colorOutline
paint.style = Paint.Style.STROKE
drawText(text, x, y, paint)
paint.color = colorText
paint.style = Paint.Style.FILL
drawText(text, x, y, paint)
}
private fun updateCutoutInsets(insetsCompat: WindowInsetsCompat?) {
val cutouts = (insetsCompat ?: return).displayCutout?.boundingRects.orEmpty()
cutoutInsetLeft = 0
cutoutInsetRight = 0
for (rect in cutouts) {
if (rect.left <= paddingLeft) {
cutoutInsetLeft += rect.width()
}
if (rect.right >= width - paddingRight) {
cutoutInsetRight += rect.width()
}
}
}
private inner class TimeReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
timeText = timeFormat.format(Date())
invalidate()
}
}
}
@@ -70,14 +70,18 @@ class LoadMoreView(context: Context, attrs: AttributeSet? = null) : FrameLayout(
binding.tvText.visible()
}
fun error(msg: String) {
fun error(msg: String?, text: String = "") {
stopLoad()
hasMore = false
errorMsg = msg
binding.tvText.text = context.getString(R.string.error_load_msg, "点击查看详情")
errorMsg = msg ?: ""
binding.tvText.text =
text.ifEmpty { context.getString(R.string.error_load_msg, "点击查看详情") }
binding.tvText.visible()
}
fun getLoading() = binding.rotateLoading
fun getLoadingText() = binding.tvText
private fun showErrorDialog(): Boolean {
if (errorMsg.isBlank()) {
return false
@@ -18,6 +18,7 @@ import androidx.annotation.ColorInt
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.core.view.WindowInsetsControllerCompat.BEHAVIOR_DEFAULT
import androidx.core.view.WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
import androidx.fragment.app.DialogFragment
@@ -243,4 +244,9 @@ val Activity.navigationBarGravity: Int
fun AppCompatActivity.showHelp(fileName: String) {
val mdText = String(assets.open("web/help/md/${fileName}.md").readBytes())
showDialogFragment(TextDialog(getString(R.string.help), mdText, TextDialog.Mode.MD))
}
fun immersionFullScreen(windowInsetsControllerCompat: WindowInsetsControllerCompat) {
windowInsetsControllerCompat.systemBarsBehavior = BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
windowInsetsControllerCompat.hide(WindowInsetsCompat.Type.systemBars())
}
@@ -38,7 +38,10 @@ import androidx.preference.PreferenceManager
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
import io.legado.app.R
import io.legado.app.constant.AppConst
import io.legado.app.data.entities.Book
import io.legado.app.help.IntentHelp
import io.legado.app.help.book.isImage
import io.legado.app.help.config.AppConfig
import splitties.systemservices.clipboardManager
import splitties.systemservices.connectivityManager
import splitties.systemservices.uiModeManager
@@ -53,6 +56,18 @@ inline fun <reified A : Activity> Context.startActivity(configIntent: Intent.()
startActivity(intent)
}
inline fun <reified A : Activity, reified M : Activity> Context.startReadOrMangaActivity(
book: Book,
configIntent: Intent.() -> Unit = {},
) {
val intent =
Intent(this, if (book.isImage && AppConfig.showMangaUi) M::class.java else A::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
intent.apply(configIntent)
startActivity(intent)
}
inline fun <reified T : Service> Context.startService(configIntent: Intent.() -> Unit = {}) {
startService(Intent(this, T::class.java).apply(configIntent))
}
@@ -65,7 +80,7 @@ inline fun <reified T : Service> Context.stopService() {
inline fun <reified T : Service> Context.servicePendingIntent(
action: String,
requestCode: Int = 0,
configIntent: Intent.() -> Unit = {}
configIntent: Intent.() -> Unit = {},
): PendingIntent? {
val intent = Intent(this, T::class.java)
intent.action = action
@@ -81,7 +96,7 @@ inline fun <reified T : Service> Context.servicePendingIntent(
@SuppressLint("UnspecifiedImmutableFlag")
fun Context.activityPendingIntent(
intent: Intent,
action: String
action: String,
): PendingIntent? {
intent.action = action
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
@@ -95,7 +110,7 @@ fun Context.activityPendingIntent(
@SuppressLint("UnspecifiedImmutableFlag")
inline fun <reified T : Activity> Context.activityPendingIntent(
action: String,
configIntent: Intent.() -> Unit = {}
configIntent: Intent.() -> Unit = {},
): PendingIntent? {
val intent = Intent(this, T::class.java)
intent.action = action
@@ -111,7 +126,7 @@ inline fun <reified T : Activity> Context.activityPendingIntent(
@SuppressLint("UnspecifiedImmutableFlag")
inline fun <reified T : BroadcastReceiver> Context.broadcastPendingIntent(
action: String,
configIntent: Intent.() -> Unit = {}
configIntent: Intent.() -> Unit = {},
): PendingIntent? {
val intent = Intent(this, T::class.java)
intent.action = action
@@ -161,7 +176,7 @@ fun Context.putPrefString(key: String, value: String?) =
fun Context.getPrefStringSet(
key: String,
defValue: MutableSet<String>? = null
defValue: MutableSet<String>? = null,
): MutableSet<String>? = defaultSharedPreferences.getStringSet(key, defValue)
fun Context.putPrefStringSet(key: String, value: MutableSet<String>) =
@@ -255,7 +270,7 @@ fun Context.share(file: File, type: String = "text/*") {
fun Context.shareWithQr(
text: String,
title: String = getString(R.string.share),
errorCorrectionLevel: ErrorCorrectionLevel = ErrorCorrectionLevel.H
errorCorrectionLevel: ErrorCorrectionLevel = ErrorCorrectionLevel.H,
) {
val bitmap = QRCodeUtils.createQRCode(text, errorCorrectionLevel = errorCorrectionLevel)
if (bitmap == null) {
@@ -14,6 +14,9 @@ import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import io.legado.app.R
import io.legado.app.data.entities.Book
import io.legado.app.help.book.isImage
import io.legado.app.help.config.AppConfig
import io.legado.app.ui.widget.dialog.TextDialog
inline fun <reified T : DialogFragment> Fragment.showDialogFragment(
@@ -81,6 +84,19 @@ inline fun <reified T : Activity> Fragment.startActivity(
startActivity(Intent(requireContext(), T::class.java).apply(configIntent))
}
inline fun <reified A : Activity, reified M : Activity> Fragment.startReadOrMangaActivity(
book: Book,
configIntent: Intent.() -> Unit = {},
) {
val intent = Intent(
requireActivity(),
if (book.isImage && AppConfig.showMangaUi) M::class.java else A::class.java
)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
intent.apply(configIntent)
startActivity(intent)
}
fun Fragment.showHelp(fileName: String) {
val mdText = String(requireContext().assets.open("web/help/md/${fileName}.md").readBytes())
showDialogFragment(TextDialog(getString(R.string.help), mdText, TextDialog.Mode.MD))
@@ -0,0 +1,28 @@
package io.legado.app.utils
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
fun RecyclerView.findCenterViewPosition(): Int {
return getChildAdapterPosition(findChildViewUnder(width / 2f, height / 2f) ?: return RecyclerView.NO_POSITION)
}
fun RecyclerView.findViewPosition(x: Float, y: Float): Int {
return getChildAdapterPosition(findChildViewUnder(x, y) ?: return RecyclerView.NO_POSITION)
}
fun RecyclerView.findFirstVisibleViewPosition(): Int {
var pos = -1
if (layoutManager is LinearLayoutManager) {
pos = (layoutManager as LinearLayoutManager).findFirstVisibleItemPosition()
}
return pos
}
fun RecyclerView.findLastVisibleViewPosition(): Int {
var pos = -1
if ( layoutManager is LinearLayoutManager) {
pos = (layoutManager as LinearLayoutManager).findLastVisibleItemPosition()
}
return pos
}
@@ -0,0 +1,93 @@
<?xml version="1.0" encoding="utf-8"?>
<io.legado.app.ui.book.manga.rv.WebtoonFrame xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<io.legado.app.ui.book.manga.rv.WebtoonRecyclerView
android:id="@+id/mRecyclerMange"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<io.legado.app.ui.widget.ReaderInfoBarView
android:id="@+id/infobar"
android:layout_width="match_parent"
android:layout_height="20dp"
android:layout_gravity="bottom"
android:layout_marginBottom="10dp"
android:paddingStart="10dp"
android:paddingEnd="10dp"
android:visibility="gone"
tools:visibility="visible" />
<io.legado.app.ui.book.read.MangaMenu
android:id="@+id/mangaMenu"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone"
tools:visibility="visible" />
<FrameLayout
android:id="@+id/fl_loading"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/white">
<LinearLayout
android:id="@+id/ll_loading"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:orientation="vertical">
<ProgressBar
style="@style/Widget.Material3.CircularProgressIndicator.Small"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:indeterminate="true"
app:indicatorColor="@color/white"
app:indicatorSize="36dp"
app:trackCornerRadius="99dp"
app:trackThickness="3dp" />
<TextView
android:id="@+id/tv_loading_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="正在加载中..."
android:textColor="@color/black" />
</LinearLayout>
<LinearLayout
android:id="@+id/ll_retry"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone"
android:gravity="center"
android:orientation="vertical">
<TextView
android:id="@+id/tv_msg"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/black" />
<Button
android:id="@+id/retry"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="重新加载"
android:textSize="18sp"
app:cornerRadius="10dp"
tools:visibility="visible" />
</LinearLayout>
</FrameLayout>
</io.legado.app.ui.book.manga.rv.WebtoonFrame>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="96dp"
android:background="@color/book_ant_10">
<TextView
android:id="@+id/text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:textColor="@android:color/white"
android:textStyle="bold"
tools:text="上一章节:第一话" />
</FrameLayout>
+64
View File
@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/rootView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/book_ant_10"
android:contentDescription="@null">
<com.github.panpf.zoomimage.GlideZoomImageView
android:id="@+id/image"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:adjustViewBounds="true"
app:contentScale="fillWidth" />
<FrameLayout
android:id="@+id/fl_progress"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/book_ant_10">
<ProgressBar
android:id="@+id/loading"
style="@style/Widget.Material3.CircularProgressIndicator.Small"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminate="true"
android:indeterminateTint="@color/white"
android:progressTint="@color/white"
app:indicatorColor="@color/white"
app:indicatorSize="36dp"
app:trackCornerRadius="99dp"
app:trackThickness="3dp" />
<Button
android:id="@+id/retry"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="重新加载"
android:textColor="@color/white"
android:textSize="18sp"
android:visibility="gone"
app:backgroundTint="@color/white"
app:cornerRadius="10dp"
tools:visibility="visible" />
<TextView
android:id="@+id/progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="0%"
android:textColor="@color/white"
android:visibility="gone"
tools:visibility="visible" />
</FrameLayout>
</FrameLayout>
@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/vw_menu_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<View
android:id="@+id/vw_menu_bg"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:contentDescription="@string/content"
tools:layout_editor_absoluteX="0dp"
tools:layout_editor_absoluteY="0dp" />
<io.legado.app.ui.widget.TitleBar
android:id="@+id/title_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:theme="?attr/actionBarStyle"
app:layout_constraintTop_toTopOf="parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/title_bar_addition"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/tv_chapter_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:paddingLeft="10dp"
android:paddingRight="10dp"
android:singleLine="true"
android:visibility="gone"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toLeftOf="@+id/tv_source_action"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tv_chapter_url"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:paddingLeft="10dp"
android:paddingRight="10dp"
android:singleLine="true"
android:visibility="gone"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toLeftOf="@+id/tv_source_action"
app:layout_constraintTop_toBottomOf="@+id/tv_chapter_name" />
<io.legado.app.ui.widget.text.AccentBgTextView
android:id="@+id/tv_source_action"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_margin="1dp"
android:gravity="center"
android:maxWidth="120dp"
android:paddingLeft="6dp"
android:paddingRight="6dp"
android:text="@string/book_source"
app:layout_constraintBottom_toBottomOf="@+id/tv_chapter_url"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:radius="2dp" />
</androidx.constraintlayout.widget.ConstraintLayout>
</io.legado.app.ui.widget.TitleBar>
</androidx.constraintlayout.widget.ConstraintLayout>
+21
View File
@@ -0,0 +1,21 @@
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:context=".ui.main.MainActivity"
tools:ignore="AlwaysShowAction">
<group android:id="@+id/menu_group_on_line">
<item
android:id="@+id/menu_change_source"
android:icon="@drawable/ic_exchange"
android:title="@string/change_origin"
app:showAsAction="always" />
<item
android:id="@+id/menu_catalog"
android:icon="@drawable/ic_toc"
android:title="@string/chapter_list"
app:showAsAction="always" />
</group>
</menu>
@@ -1180,6 +1180,9 @@
<string name="read_aloud_by_media_button_summary">通过耳机按键来启动朗读</string>
<string name="show_same_source">显示重复书源</string>
<string name="theme_config">主题配置</string>
<string name="show_mange_ui">漫画浏览</string>
<string name="disable_manga_scaling">禁用漫画缩放</string>
<string name="book_reader_info_bar">页数. %1$d/%2$d 章节. %3$d/%4$d -> %5$s%%</string>
<string name="menu_download_after">Descargar el siguiente capítulo</string>
<string name="menu_download_all">Descargar todos los capítulos</string>
</resources>
@@ -1183,6 +1183,9 @@
<string name="read_aloud_by_media_button_summary">通过耳机按键来启动朗读</string>
<string name="show_same_source">显示重复书源</string>
<string name="theme_config">主题配置</string>
<string name="show_mange_ui">漫画浏览</string>
<string name="disable_manga_scaling">禁用漫画缩放</string>
<string name="book_reader_info_bar">页数. %1$d/%2$d 章节. %3$d/%4$d -> %5$s%%</string>
<string name="menu_download_after">下载之后章节</string>
<string name="menu_download_all">下载全部章节</string>
</resources>
@@ -1183,6 +1183,9 @@
<string name="read_aloud_by_media_button_summary">通过耳机按键来启动朗读</string>
<string name="show_same_source">显示重复书源</string>
<string name="theme_config">主题配置</string>
<string name="show_mange_ui">漫画浏览</string>
<string name="disable_manga_scaling">禁用漫画缩放</string>
<string name="book_reader_info_bar">页数. %1$d/%2$d 章节. %3$d/%4$d -> %5$s%%</string>
<string name="menu_download_after">Faça o download do próximo capítulo</string>
<string name="menu_download_all">Download de todos os capítulos</string>
</resources>
+3
View File
@@ -1179,6 +1179,9 @@ Còn </string>
<string name="read_aloud_by_media_button_summary">通过耳机按键来启动朗读</string>
<string name="show_same_source">显示重复书源</string>
<string name="theme_config">主题配置</string>
<string name="show_mange_ui">漫画浏览</string>
<string name="disable_manga_scaling">禁用漫画缩放</string>
<string name="book_reader_info_bar">页数. %1$d/%2$d 章节. %3$d/%4$d -> %5$s%%</string>
<string name="menu_download_after">Tải xuống chương tiếp theo</string>
<string name="menu_download_all">Tải xuống tất cả các chương</string>
</resources>
@@ -1180,6 +1180,9 @@
<string name="read_aloud_by_media_button_summary">通过耳机按键来启动朗读</string>
<string name="show_same_source">显示重复书源</string>
<string name="theme_config">主题配置</string>
<string name="show_mange_ui">漫画浏览</string>
<string name="disable_manga_scaling">禁用漫画缩放</string>
<string name="book_reader_info_bar">页数. %1$d/%2$d 章节. %3$d/%4$d -> %5$s%%</string>
<string name="menu_download_after">下載之後章節</string>
<string name="menu_download_all">下載全部章節</string>
</resources>
@@ -1182,6 +1182,9 @@
<string name="read_aloud_by_media_button_summary">通过耳机按键来启动朗读</string>
<string name="show_same_source">显示重复书源</string>
<string name="theme_config">主题配置</string>
<string name="show_mange_ui">漫画浏览</string>
<string name="disable_manga_scaling">禁用漫画缩放</string>
<string name="book_reader_info_bar">页数. %1$d/%2$d 章节. %3$d/%4$d -> %5$s%%</string>
<string name="menu_download_after">下載之後章節</string>
<string name="menu_download_all">下載全部章節</string>
</resources>
+3
View File
@@ -1182,6 +1182,9 @@
<string name="read_aloud_by_media_button_summary">通过耳机按键来启动朗读</string>
<string name="show_same_source">显示重复书源</string>
<string name="theme_config">主题配置</string>
<string name="show_mange_ui">漫画浏览</string>
<string name="disable_manga_scaling">禁用漫画缩放</string>
<string name="book_reader_info_bar">页数. %1$d/%2$d 章节. %3$d/%4$d -> %5$s%%</string>
<string name="menu_download_after">下载之后章节</string>
<string name="menu_download_all">下载全部章节</string>
</resources>
+2
View File
@@ -111,4 +111,6 @@
<color name="ate_switch_thumb_disabled_dark">#FF424242</color>
<!---->
<color name="ate_navigation_drawer_selected_dark">#202020</color>
<color name="book_ant_10">#141414</color>
</resources>
+3
View File
@@ -1183,6 +1183,9 @@
<string name="read_aloud_by_media_button_summary">通过耳机按键来启动朗读</string>
<string name="show_same_source">显示重复书源</string>
<string name="theme_config">主题配置</string>
<string name="show_mange_ui">漫画浏览</string>
<string name="disable_manga_scaling">禁用漫画缩放</string>
<string name="book_reader_info_bar">页数. %1$d/%2$d 章节. %3$d/%4$d -> %5$s%%</string>
<string name="menu_download_after">Download the chapter after</string>
<string name="menu_download_all">Download all chapter</string>
</resources>
@@ -42,6 +42,18 @@
android:title="@string/show_rss"
app:iconSpaceReserved="false" />
<io.legado.app.lib.prefs.SwitchPreference
android:defaultValue="true"
android:key="showMangaUi"
android:title="@string/show_mange_ui"
app:iconSpaceReserved="false" />
<io.legado.app.lib.prefs.SwitchPreference
android:defaultValue="true"
android:key="disableMangaScaling"
android:title="@string/disable_manga_scaling"
app:iconSpaceReserved="false" />
<io.legado.app.lib.prefs.NameListPreference
android:defaultValue="bookshelf"
android:key="defaultHomePage"
+3
View File
@@ -115,6 +115,8 @@ room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
room-testing = { module = "androidx.room:room-testing", version.ref = "room" }
glide-glide = { module = "com.github.bumptech.glide:glide", version.ref = "glide" }
glide-okhttp= { module = "com.github.bumptech.glide:okhttp3-integration", version.ref ="glide" }
glide-recyclerview={module="com.github.bumptech.glide:recyclerview-integration",version.ref="glide"}
glide-compiler = { module = "com.github.bumptech.glide:compiler", version.ref = "glide" }
glide-compose = { module = "com.github.bumptech.glide:compose", version = "1.0.0-beta01" }
glide-ksp = { module = "com.github.bumptech.glide:ksp", version.ref = "glide" }
@@ -159,6 +161,7 @@ mozilla-rhino = { module = "org.mozilla:rhino", version.ref = "rhino" }
renderscript-intrinsics-replacement-toolkit = { module = "com.github.android:renderscript-intrinsics-replacement-toolkit", version = "b6363490c3" }
zxing-lite = { module = "com.github.jenly1314:zxing-lite", version.ref = "zxingLite" }
zoom-imageview="io.github.panpf.zoomimage:zoomimage-view-glide:1.1.1"
[bundles]
coroutines = ["kotlinx-coroutines-core", "kotlinx-coroutines-android"]