diff --git a/app/build.gradle b/app/build.gradle index e9db05bed..7e18c25b4 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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') diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 07a76c223..ecb309347 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -24,6 +24,7 @@ + + + + + + + + + (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() } } diff --git a/app/src/main/java/io/legado/app/constant/PreferKey.kt b/app/src/main/java/io/legado/app/constant/PreferKey.kt index bcf5fb8fa..b5960674c 100644 --- a/app/src/main/java/io/legado/app/constant/PreferKey.kt +++ b/app/src/main/java/io/legado/app/constant/PreferKey.kt @@ -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" diff --git a/app/src/main/java/io/legado/app/help/config/AppConfig.kt b/app/src/main/java/io/legado/app/help/config/AppConfig.kt index 2afadab09..3d25d5d22 100644 --- a/app/src/main/java/io/legado/app/help/config/AppConfig.kt +++ b/app/src/main/java/io/legado/app/help/config/AppConfig.kt @@ -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) } diff --git a/app/src/main/java/io/legado/app/help/glide/LegadoGlideModule.kt b/app/src/main/java/io/legado/app/help/glide/LegadoGlideModule.kt index bed786283..dde9cba19 100644 --- a/app/src/main/java/io/legado/app/help/glide/LegadoGlideModule.kt +++ b/app/src/main/java/io/legado/app/help/glide/LegadoGlideModule.kt @@ -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) + } + } } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/glide/OkHttpModeLoaderFactory.kt b/app/src/main/java/io/legado/app/help/glide/OkHttpModeLoaderFactory.kt index 7b99fb0fb..6be342699 100644 --- a/app/src/main/java/io/legado/app/help/glide/OkHttpModeLoaderFactory.kt +++ b/app/src/main/java/io/legado/app/help/glide/OkHttpModeLoaderFactory.kt @@ -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 { +object OkHttpModeLoaderFactory: ModelLoaderFactory { override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader { return OkHttpModelLoader diff --git a/app/src/main/java/io/legado/app/help/glide/OkHttpModelLoader.kt b/app/src/main/java/io/legado/app/help/glide/OkHttpModelLoader.kt index e7dc687f0..1f813a7c1 100644 --- a/app/src/main/java/io/legado/app/help/glide/OkHttpModelLoader.kt +++ b/app/src/main/java/io/legado/app/help/glide/OkHttpModelLoader.kt @@ -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 { val loadOnlyWifiOption = Option.memory("loadOnlyWifi", false) val sourceOriginOption = Option.memory("sourceOrigin") + val mangaOption = Option.memory("manga",false) override fun buildLoadData( model: GlideUrl, width: Int, height: Int, - options: Options + options: Options, ): ModelLoader.LoadData { 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 { diff --git a/app/src/main/java/io/legado/app/help/glide/OkHttpStreamFetcher.kt b/app/src/main/java/io/legado/app/help/glide/OkHttpStreamFetcher.kt index d7409b0fe..5e9895961 100644 --- a/app/src/main/java/io/legado/app/help/glide/OkHttpStreamFetcher.kt +++ b/app/src/main/java/io/legado/app/help/glide/OkHttpStreamFetcher.kt @@ -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, 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) } diff --git a/app/src/main/java/io/legado/app/help/glide/progress/OnProgressListener.kt b/app/src/main/java/io/legado/app/help/glide/progress/OnProgressListener.kt new file mode 100644 index 000000000..19561c75b --- /dev/null +++ b/app/src/main/java/io/legado/app/help/glide/progress/OnProgressListener.kt @@ -0,0 +1,3 @@ +package io.legado.app.help.glide.progress + +typealias OnProgressListener = ((isComplete: Boolean, percentage: Int, bytesRead: Long, totalBytes: Long) -> Unit)? \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/glide/progress/ProgressManager.kt b/app/src/main/java/io/legado/app/help/glide/progress/ProgressManager.kt new file mode 100644 index 000000000..0cebbc5f0 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/glide/progress/ProgressManager.kt @@ -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() + + 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] + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/glide/progress/ProgressResponseBody.kt b/app/src/main/java/io/legado/app/help/glide/progress/ProgressResponseBody.kt new file mode 100644 index 000000000..9a8158a9a --- /dev/null +++ b/app/src/main/java/io/legado/app/help/glide/progress/ProgressResponseBody.kt @@ -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()) + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/http/HttpHelper.kt b/app/src/main/java/io/legado/app/help/http/HttpHelper.kt index df8d1b217..67e30b7d3 100644 --- a/app/src/main/java/io/legado/app/help/http/HttpHelper.kt +++ b/app/src/main/java/io/legado/app/help/http/HttpHelper.kt @@ -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) { diff --git a/app/src/main/java/io/legado/app/model/BookCover.kt b/app/src/main/java/io/legado/app/model/BookCover.kt index 477fb8de2..a458c0de7 100644 --- a/app/src/main/java/io/legado/app/model/BookCover.kt +++ b/app/src/main/java/io/legado/app/model/BookCover.kt @@ -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 { 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 { + 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 { val loadBlur = ImageLoader.load(context, defaultDrawable) .transform(BlurTransformation(25), CenterCrop()) diff --git a/app/src/main/java/io/legado/app/model/ReadMange.kt b/app/src/main/java/io/legado/app/model/ReadMange.kt new file mode 100644 index 000000000..a87773dd6 --- /dev/null +++ b/app/src/main/java/io/legado/app/model/ReadMange.kt @@ -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() + var simulatedChapterSize = 0 + var mCallback: Callback? = null + var mFirstLoading = false + var gameOver = false + var mTopChapter: BookChapter? = null + var preDownloadTask: Job? = null + val downloadedChapters = hashSetOf() + val downloadFailChapters = hashMapOf() + private val downloadLoadingChapters = arrayListOf() + 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() + 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) + fun loadComplete() + fun loadFail(msg: String) + fun noData() + fun adjustmentProgress() + fun sureNewProgress(progress: BookProgress) + val chapterList: MutableList + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/model/recyclerView/MangaVH.kt b/app/src/main/java/io/legado/app/model/recyclerView/MangaVH.kt new file mode 100644 index 000000000..c51aeba7a --- /dev/null +++ b/app/src/main/java/io/legado/app/model/recyclerView/MangaVH.kt @@ -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(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 { + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target, + isFirstResource: Boolean, + ): Boolean { + mFlProgress.isVisible = true + mLoading.isGone = true + mRetry?.isVisible = true + mProgress.isGone = true + itemView.updateLayoutParams { + height = MATCH_PARENT + } + return false + } + + override fun onResourceReady( + resource: Drawable, + model: Any, + target: Target?, + dataSource: DataSource, + isFirstResource: Boolean, + ): Boolean { + mFlProgress.isGone = true + itemView.updateLayoutParams { + height = WRAP_CONTENT + } + return false + } + }).into(mImage) + } catch (e: Exception) { + e.printOnDebug() + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/model/recyclerView/MangeContent.kt b/app/src/main/java/io/legado/app/model/recyclerView/MangeContent.kt new file mode 100644 index 000000000..0f7160db0 --- /dev/null +++ b/app/src/main/java/io/legado/app/model/recyclerView/MangeContent.kt @@ -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//当前章节内容总数 +) \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/model/recyclerView/ReaderLoading.kt b/app/src/main/java/io/legado/app/model/recyclerView/ReaderLoading.kt new file mode 100644 index 000000000..ad7dcf9b1 --- /dev/null +++ b/app/src/main/java/io/legado/app/model/recyclerView/ReaderLoading.kt @@ -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, +) diff --git a/app/src/main/java/io/legado/app/ui/about/ReadRecordActivity.kt b/app/src/main/java/io/legado/app/ui/about/ReadRecordActivity.kt index c14d3ba18..516b04afe 100644 --- a/app/src/main/java/io/legado/app/ui/about/ReadRecordActivity.kt +++ b/app/src/main/java/io/legado/app/ui/about/ReadRecordActivity.kt @@ -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() { 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() { holder: ItemViewHolder, binding: ItemReadRecordBinding, item: ReadRecordShow, - payloads: MutableList + payloads: MutableList, ) { binding.apply { tvBookName.text = item.bookName @@ -189,7 +193,7 @@ class ReadRecordActivity : BaseActivity() { if (book == null) { SearchActivity.start(this@ReadRecordActivity, item.bookName) } else { - startActivity { + startReadOrMangaActivity(book) { putExtra("bookUrl", book.bookUrl) } } diff --git a/app/src/main/java/io/legado/app/ui/association/FileAssociationActivity.kt b/app/src/main/java/io/legado/app/ui/association/FileAssociationActivity.kt index da99ac719..fbcfb8523 100644 --- a/app/src/main/java/io/legado/app/ui/association/FileAssociationActivity.kt +++ b/app/src/main/java/io/legado/app/ui/association/FileAssociationActivity.kt @@ -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 { - putExtra("bookUrl", it) + startReadOrMangaActivity(it) { + putExtra("bookUrl", it.bookUrl) } finish() } diff --git a/app/src/main/java/io/legado/app/ui/association/FileAssociationViewModel.kt b/app/src/main/java/io/legado/app/ui/association/FileAssociationViewModel.kt index f16beb563..08a03674c 100644 --- a/app/src/main/java/io/legado/app/ui/association/FileAssociationViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/association/FileAssociationViewModel.kt @@ -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() val onLineImportLive = MutableLiveData() - val openBookLiveData = MutableLiveData() + val openBookLiveData = MutableLiveData() val notSupportedLiveData = MutableLiveData>() 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) } } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/audio/AudioPlayActivity.kt b/app/src/main/java/io/legado/app/ui/book/audio/AudioPlayActivity.kt index 747ccd744..bab16cdab 100644 --- a/app/src/main/java/io/legado/app/ui/book/audio/AudioPlayActivity.kt +++ b/app/src/main/java/io/legado/app/ui/book/audio/AudioPlayActivity.kt @@ -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 { + startReadOrMangaActivity(book) { putExtra("bookUrl", book.bookUrl) } finish() diff --git a/app/src/main/java/io/legado/app/ui/book/import/BaseImportBookActivity.kt b/app/src/main/java/io/legado/app/ui/book/import/BaseImportBookActivity.kt index aec1cf321..d61d298d6 100644 --- a/app/src/main/java/io/legado/app/ui/book/import/BaseImportBookActivity.kt +++ b/app/src/main/java/io/legado/app/ui/book/import/BaseImportBookActivity.kt @@ -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 : VMBaseActivity { - putExtra("bookUrl", bookUrl) + protected fun startReadBook(book: Book) { + startReadOrMangaActivity(book) { + putExtra("bookUrl", book.bookUrl) } } @@ -110,7 +111,7 @@ abstract class BaseImportBookActivity : VMBaseActivity : VMBaseActivity appDb.bookDao.getBookByFileName(name)?.let { - startReadBook(it.bookUrl) + startReadBook(it) } ?: showImportAlert(fileDoc, name) } } @@ -136,12 +137,12 @@ abstract class BaseImportBookActivity : VMBaseActivity Unit + onSuccess: (Book) -> Unit ) { LocalBook.importArchiveFile(fileDoc.uri, fileName) { it.contains(fileName) }.firstOrNull()?.run { - onSuccess.invoke(bookUrl) + onSuccess.invoke(this) } } diff --git a/app/src/main/java/io/legado/app/ui/book/import/local/ImportBookActivity.kt b/app/src/main/java/io/legado/app/ui/book/import/local/ImportBookActivity.kt index 5eb7c58cd..0901d1d0e 100644 --- a/app/src/main/java/io/legado/app/ui/book/import/local/ImportBookActivity.kt +++ b/app/src/main/java/io/legado/app/ui/book/import/local/ImportBookActivity.kt @@ -304,7 +304,7 @@ class ImportBookActivity : BaseImportBookActivity(), override fun startRead(fileDoc: FileDoc) { if (!ArchiveUtils.isArchive(fileDoc.name)) { appDb.bookDao.getBookByFileName(fileDoc.name)?.let { - startReadBook(it.bookUrl) + startReadBook(it) } } else { onArchiveFileClick(fileDoc) diff --git a/app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookActivity.kt b/app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookActivity.kt index 5951f7813..5c30c8cd3 100644 --- a/app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookActivity.kt +++ b/app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookActivity.kt @@ -228,7 +228,7 @@ class RemoteBookActivity : BaseImportBookActivity(), val downloadFileName = remoteBook.filename if (!ArchiveUtils.isArchive(downloadFileName)) { appDb.bookDao.getBookByFileName(downloadFileName)?.let { - startReadBook(it.bookUrl) + startReadBook(it) } } else { AppConfig.defaultBookTreeUri ?: return diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoActivity.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoActivity.kt index 6c968978b..98c33a4a9 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/BookInfoActivity.kt +++ b/app/src/main/java/io/legado/app/ui/book/info/BookInfoActivity.kt @@ -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, - 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) diff --git a/app/src/main/java/io/legado/app/ui/book/manga/MangaViewModel.kt b/app/src/main/java/io/legado/app/ui/book/manga/MangaViewModel.kt new file mode 100644 index 000000000..7c552d1bc --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/manga/MangaViewModel.kt @@ -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) { + 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() + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/manga/ReadMangaActivity.kt b/app/src/main/java/io/legado/app/ui/book/manga/ReadMangaActivity.kt new file mode 100644 index 000000000..660ab693f --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/manga/ReadMangaActivity.kt @@ -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(), + 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( + 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() + + 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) { + 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 + 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) { + 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) + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/manga/rv/GestureDetectorWithLongTap.kt b/app/src/main/java/io/legado/app/ui/book/manga/rv/GestureDetectorWithLongTap.kt new file mode 100644 index 000000000..cc5397d47 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/manga/rv/GestureDetectorWithLongTap.kt @@ -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) { + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/manga/rv/MangaAdapter.kt b/app/src/main/java/io/legado/app/ui/book/manga/rv/MangaAdapter.kt new file mode 100644 index 000000000..feee936d7 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/manga/rv/MangaAdapter.kt @@ -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(), PreloadModelProvider { + + companion object { + private const val LOADING_VIEW = 0 + private const val CONTENT_VIEW = 1 + } + + private val mDiffCallback: DiffUtil.ItemCallback = object : DiffUtil.ItemCallback() { + 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, 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(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 { + 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 { + 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 + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/manga/rv/WebtoonFrame.kt b/app/src/main/java/io/legado/app/ui/book/manga/rv/WebtoonFrame.kt new file mode 100644 index 000000000..276425149 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/manga/rv/WebtoonFrame.kt @@ -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 + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/manga/rv/WebtoonRecyclerView.kt b/app/src/main/java/io/legado/app/ui/book/manga/rv/WebtoonRecyclerView.kt new file mode 100644 index 000000000..c7cac1235 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/manga/rv/WebtoonRecyclerView.kt @@ -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 diff --git a/app/src/main/java/io/legado/app/ui/book/read/MangaMenu.kt b/app/src/main/java/io/legado/app/ui/book/read/MangaMenu.kt new file mode 100644 index 000000000..0c8580821 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/MangaMenu.kt @@ -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 { + 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) + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/style1/books/BooksFragment.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/style1/books/BooksFragment.kt index af9a88ce8..bf7fb051a 100644 --- a/app/src/main/java/io/legado/app/ui/main/bookshelf/style1/books/BooksFragment.kt +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/style1/books/BooksFragment.kt @@ -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 { + else -> startReadOrMangaActivity(book) { putExtra("bookUrl", book.bookUrl) } } diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/style2/BookshelfFragment2.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/style2/BookshelfFragment2.kt index 6df5eab71..24dae4285 100644 --- a/app/src/main/java/io/legado/app/ui/main/bookshelf/style2/BookshelfFragment2.kt +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/style2/BookshelfFragment2.kt @@ -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 { putExtra("bookUrl", item.bookUrl) } - else -> startActivity { + else -> startReadOrMangaActivity(item) { putExtra("bookUrl", item.bookUrl) } } diff --git a/app/src/main/java/io/legado/app/ui/widget/ReaderInfoBarView.kt b/app/src/main/java/io/legado/app/ui/widget/ReaderInfoBarView.kt new file mode 100644 index 000000000..37df004dc --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/ReaderInfoBarView.kt @@ -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() + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/recycler/LoadMoreView.kt b/app/src/main/java/io/legado/app/ui/widget/recycler/LoadMoreView.kt index 53f20f0c2..d13e034b9 100644 --- a/app/src/main/java/io/legado/app/ui/widget/recycler/LoadMoreView.kt +++ b/app/src/main/java/io/legado/app/ui/widget/recycler/LoadMoreView.kt @@ -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 diff --git a/app/src/main/java/io/legado/app/utils/ActivityExtensions.kt b/app/src/main/java/io/legado/app/utils/ActivityExtensions.kt index 72552b5b7..51a092ed4 100644 --- a/app/src/main/java/io/legado/app/utils/ActivityExtensions.kt +++ b/app/src/main/java/io/legado/app/utils/ActivityExtensions.kt @@ -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()) } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/utils/ContextExtensions.kt b/app/src/main/java/io/legado/app/utils/ContextExtensions.kt index 94840a39b..874e49654 100644 --- a/app/src/main/java/io/legado/app/utils/ContextExtensions.kt +++ b/app/src/main/java/io/legado/app/utils/ContextExtensions.kt @@ -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 Context.startActivity(configIntent: Intent.() startActivity(intent) } +inline fun 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 Context.startService(configIntent: Intent.() -> Unit = {}) { startService(Intent(this, T::class.java).apply(configIntent)) } @@ -65,7 +80,7 @@ inline fun Context.stopService() { inline fun 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 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 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 Context.activityPendingIntent( @SuppressLint("UnspecifiedImmutableFlag") inline fun 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? = null + defValue: MutableSet? = null, ): MutableSet? = defaultSharedPreferences.getStringSet(key, defValue) fun Context.putPrefStringSet(key: String, value: MutableSet) = @@ -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) { diff --git a/app/src/main/java/io/legado/app/utils/FragmentExtensions.kt b/app/src/main/java/io/legado/app/utils/FragmentExtensions.kt index 432d0690f..9a143d5cc 100644 --- a/app/src/main/java/io/legado/app/utils/FragmentExtensions.kt +++ b/app/src/main/java/io/legado/app/utils/FragmentExtensions.kt @@ -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 Fragment.showDialogFragment( @@ -81,6 +84,19 @@ inline fun Fragment.startActivity( startActivity(Intent(requireContext(), T::class.java).apply(configIntent)) } +inline fun 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)) diff --git a/app/src/main/java/io/legado/app/utils/RecyclerViewExtensions.kt b/app/src/main/java/io/legado/app/utils/RecyclerViewExtensions.kt new file mode 100644 index 000000000..5a364992c --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/RecyclerViewExtensions.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/res/layout/activity_mange.xml b/app/src/main/res/layout/activity_mange.xml new file mode 100644 index 000000000..626c749c0 --- /dev/null +++ b/app/src/main/res/layout/activity_mange.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + +