diff --git a/app/src/debug/java/org/koitharu/kotatsu/utils/LoggingAdapterDataObserver.kt b/app/src/debug/java/org/koitharu/kotatsu/utils/LoggingAdapterDataObserver.kt new file mode 100644 index 000000000..e4221be71 --- /dev/null +++ b/app/src/debug/java/org/koitharu/kotatsu/utils/LoggingAdapterDataObserver.kt @@ -0,0 +1,37 @@ +package org.koitharu.kotatsu.utils + +import android.util.Log +import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver + +class LoggingAdapterDataObserver( + private val tag: String, +) : AdapterDataObserver() { + + override fun onChanged() { + Log.d(tag, "onChanged()") + } + + override fun onItemRangeChanged(positionStart: Int, itemCount: Int) { + Log.d(tag, "onItemRangeChanged(positionStart=$positionStart, itemCount=$itemCount)") + } + + override fun onItemRangeChanged(positionStart: Int, itemCount: Int, payload: Any?) { + Log.d(tag, "onItemRangeChanged(positionStart=$positionStart, itemCount=$itemCount, payload=$payload)") + } + + override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { + Log.d(tag, "onItemRangeInserted(positionStart=$positionStart, itemCount=$itemCount)") + } + + override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) { + Log.d(tag, "onItemRangeRemoved(positionStart=$positionStart, itemCount=$itemCount)") + } + + override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) { + Log.d(tag, "onItemRangeMoved(fromPosition=$fromPosition, toPosition=$toPosition, itemCount=$itemCount)") + } + + override fun onStateRestorationPolicyChanged() { + Log.d(tag, "onStateRestorationPolicyChanged()") + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/BottomSheetHeaderBar.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/BottomSheetHeaderBar.kt index 80b5749bf..3f01e8769 100644 --- a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/BottomSheetHeaderBar.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/BottomSheetHeaderBar.kt @@ -70,6 +70,9 @@ class BottomSheetHeaderBar @JvmOverloads constructor( binding.toolbar.subtitle = value } + val isExpanded: Boolean + get() = binding.dragHandle.isGone + init { setBackgroundResource(R.drawable.sheet_toolbar_background) layoutTransition = LayoutTransition().apply { diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarksGroupAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarksGroupAdapter.kt index a73d0a0c1..a51a00ec2 100644 --- a/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarksGroupAdapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarksGroupAdapter.kt @@ -15,6 +15,7 @@ import org.koitharu.kotatsu.list.ui.adapter.errorStateListAD import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.list.ui.model.LoadingFooter import org.koitharu.kotatsu.parsers.model.Manga import kotlin.jvm.internal.Intrinsics @@ -54,6 +55,10 @@ class BookmarksGroupAdapter( oldItem.manga.id == newItem.manga.id } + oldItem is LoadingFooter && newItem is LoadingFooter -> { + oldItem.key == newItem.key + } + else -> oldItem.javaClass == newItem.javaClass } } diff --git a/app/src/main/java/org/koitharu/kotatsu/core/AppModule.kt b/app/src/main/java/org/koitharu/kotatsu/core/AppModule.kt index 6797915b1..1797b4213 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/AppModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/AppModule.kt @@ -46,8 +46,10 @@ import org.koitharu.kotatsu.local.data.CbzFetcher import org.koitharu.kotatsu.local.data.LocalManga import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.data.LocalStorageManager +import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper import org.koitharu.kotatsu.parsers.MangaLoaderContext +import org.koitharu.kotatsu.reader.ui.thumbnails.MangaPageFetcher import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider import org.koitharu.kotatsu.settings.backup.BackupObserver import org.koitharu.kotatsu.sync.domain.SyncController @@ -136,6 +138,7 @@ interface AppModule { @ApplicationContext context: Context, okHttpClient: OkHttpClient, mangaRepositoryFactory: MangaRepository.Factory, + pagesCache: PagesCache, ): ImageLoader { val httpClientFactory = { okHttpClient.newBuilder() @@ -162,6 +165,7 @@ interface AppModule { .add(SvgDecoder.Factory()) .add(CbzFetcher.Factory()) .add(FaviconFetcher.Factory(context, okHttpClient, mangaRepositoryFactory)) + .add(MangaPageFetcher.Factory(context, okHttpClient, pagesCache, mangaRepositoryFactory)) .build(), ).build() } diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt index d5ac27063..b2c3e08ae 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt @@ -43,6 +43,7 @@ import org.koitharu.kotatsu.main.ui.owners.NoModalBottomSheetOwner import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderState +import org.koitharu.kotatsu.reader.ui.thumbnails.PagesThumbnailsSheet import org.koitharu.kotatsu.utils.ViewBadge import org.koitharu.kotatsu.utils.ext.setNavigationBarTransparentCompat import org.koitharu.kotatsu.utils.ext.textAndVisible @@ -158,13 +159,28 @@ class DetailsActivity : else -> false } - override fun onMenuItemClick(item: MenuItem): Boolean = when (item.itemId) { - R.id.action_incognito -> { - openReader(isIncognitoMode = true) - true - } + override fun onMenuItemClick(item: MenuItem): Boolean { + return when (item.itemId) { + R.id.action_incognito -> { + openReader(isIncognitoMode = true) + true + } - else -> false + R.id.action_pages_thumbs -> { + val history = viewModel.historyInfo.value?.history + PagesThumbnailsSheet.show( + fm = supportFragmentManager, + manga = viewModel.manga.value ?: return false, + chapterId = history?.chapterId + ?: viewModel.chapters.value?.firstOrNull()?.chapter?.id + ?: return false, + currentPage = history?.page ?: 0, + ) + true + } + + else -> false + } } override fun onExpansionStateChanged(headerBar: BottomSheetHeaderBar, isExpanded: Boolean) { diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/ListHeaderAD.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/ListHeaderAD.kt index 855c70c1f..feaa97b0d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/ListHeaderAD.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/ListHeaderAD.kt @@ -7,12 +7,14 @@ import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.utils.ext.setTextAndVisible fun listHeaderAD( - listener: ListHeaderClickListener, + listener: ListHeaderClickListener?, ) = adapterDelegateViewBinding( { inflater, parent -> ItemHeaderButtonBinding.inflate(inflater, parent, false) }, ) { - binding.buttonMore.setOnClickListener { - listener.onListHeaderClick(item, it) + if (listener != null) { + binding.buttonMore.setOnClickListener { + listener.onListHeaderClick(item, it) + } } bind { diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt index 1be69ef00..cabf3a0e1 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt @@ -8,6 +8,7 @@ import org.koitharu.kotatsu.core.ui.DateTimeAgo import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListHeader2 import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.list.ui.model.LoadingFooter import org.koitharu.kotatsu.list.ui.model.MangaGridModel import org.koitharu.kotatsu.list.ui.model.MangaItemModel import org.koitharu.kotatsu.list.ui.model.MangaListDetailedModel @@ -60,6 +61,10 @@ open class MangaListAdapter( oldItem.dateTimeAgo == newItem.dateTimeAgo } + oldItem is LoadingFooter && newItem is LoadingFooter -> { + oldItem.key == newItem.key + } + else -> oldItem.javaClass == newItem.javaClass } diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/LoadingFooter.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/LoadingFooter.kt index 7a754dcc6..c7c336a7a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/LoadingFooter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/LoadingFooter.kt @@ -1,6 +1,19 @@ package org.koitharu.kotatsu.list.ui.model -object LoadingFooter : ListModel { +class LoadingFooter @JvmOverloads constructor( + val key: Int = 0, +) : ListModel { - override fun equals(other: Any?): Boolean = other === LoadingFooter + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as LoadingFooter + + return key == other.key + } + + override fun hashCode(): Int { + return key + } } diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFilter.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFilter.kt index f74d30258..24e9f8a2d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFilter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFilter.kt @@ -1,5 +1,6 @@ package org.koitharu.kotatsu.local.data +import android.net.Uri import java.io.File import java.io.FileFilter import java.io.FilenameFilter @@ -21,5 +22,10 @@ class CbzFilter : FileFilter, FilenameFilter { val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) return ext == "cbz" || ext == "zip" } + + fun isUriSupported(uri: Uri): Boolean { + val scheme = uri.scheme?.lowercase(Locale.ROOT) + return scheme != null && scheme == "cbz" || scheme == "zip" + } } } diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/domain/ChaptersLoader.kt b/app/src/main/java/org/koitharu/kotatsu/reader/domain/ChaptersLoader.kt index ecd163e36..334efab8a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/domain/ChaptersLoader.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/domain/ChaptersLoader.kt @@ -63,6 +63,10 @@ class ChaptersLoader @Inject constructor( return chapterPages.size(chapterId) } + fun last() = chapterPages.last() + + fun first() = chapterPages.first() + fun snapshot() = chapterPages.toList() private suspend fun loadChapter(manga: Manga, chapterId: Long): List { diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt b/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt index a4ba27794..e4f5c1269 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt @@ -25,6 +25,7 @@ import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.local.data.CbzFilter import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaSource @@ -42,9 +43,6 @@ import javax.inject.Inject import kotlin.coroutines.AbstractCoroutineContextElement import kotlin.coroutines.CoroutineContext -private const val PROGRESS_UNDEFINED = -1f -private const val PREFETCH_LIMIT_DEFAULT = 10 - @ActivityRetainedScoped class PageLoader @Inject constructor( lifecycle: ActivityRetainedLifecycle, @@ -179,7 +177,7 @@ class PageLoader @Inject constructor( val pageUrl = getPageUrl(page) check(pageUrl.isNotBlank()) { "Cannot obtain full image url" } val uri = Uri.parse(pageUrl) - return if (uri.scheme == "cbz") { + return if (CbzFilter.isUriSupported(uri)) { runInterruptible(Dispatchers.IO) { ZipFile(uri.schemeSpecificPart) }.use { zip -> @@ -191,13 +189,7 @@ class PageLoader @Inject constructor( } } } else { - val request = Request.Builder() - .url(pageUrl) - .get() - .header(CommonHeaders.ACCEPT, "image/webp,image/png;q=0.9,image/jpeg,*/*;q=0.8") - .cacheControl(CommonHeaders.CACHE_CONTROL_NO_STORE) - .tag(MangaSource::class.java, page.source) - .build() + val request = createPageRequest(page, pageUrl) okHttp.newCall(request).await().use { response -> check(response.isSuccessful) { "Invalid response: ${response.code} ${response.message} at $pageUrl" @@ -218,6 +210,19 @@ class PageLoader @Inject constructor( override fun handleException(context: CoroutineContext, exception: Throwable) { exception.printStackTraceDebug() } + } + companion object { + + private const val PROGRESS_UNDEFINED = -1f + private const val PREFETCH_LIMIT_DEFAULT = 10 + + fun createPageRequest(page: MangaPage, pageUrl: String) = Request.Builder() + .url(pageUrl) + .get() + .header(CommonHeaders.ACCEPT, "image/webp,image/png;q=0.9,image/jpeg,*/*;q=0.8") + .cacheControl(CommonHeaders.CACHE_CONTROL_NO_STORE) + .tag(MangaSource::class.java, page.source) + .build() } } diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt index e9a314b48..b3deab1df 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt @@ -40,8 +40,8 @@ import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.databinding.ActivityReaderBinding import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter -import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.reader.ui.config.ReaderConfigBottomSheet +import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState import org.koitharu.kotatsu.reader.ui.thumbnails.OnPageSelectListener import org.koitharu.kotatsu.reader.ui.thumbnails.PagesThumbnailsSheet @@ -180,17 +180,13 @@ class ReaderActivity : } R.id.action_pages_thumbs -> { - val pages = viewModel.getCurrentChapterPages() - if (!pages.isNullOrEmpty()) { - PagesThumbnailsSheet.show( - supportFragmentManager, - pages, - title?.toString().orEmpty(), - readerManager.currentReader?.getCurrentState()?.page ?: -1, - ) - } else { - return false - } + val state = viewModel.getCurrentState() ?: return false + PagesThumbnailsSheet.show( + supportFragmentManager, + viewModel.manga ?: return false, + state.chapterId, + state.page, + ) } R.id.action_bookmark -> { @@ -259,17 +255,19 @@ class ReaderActivity : } override fun onChapterChanged(chapter: MangaChapter) { - viewModel.switchChapter(chapter.id) + viewModel.switchChapter(chapter.id, 0) } - override fun onPageSelected(page: MangaPage) { + override fun onPageSelected(page: ReaderPage) { lifecycleScope.launch(Dispatchers.Default) { val pages = viewModel.content.value?.pages ?: return@launch - val index = pages.indexOfFirst { it.id == page.id } + val index = pages.indexOfFirst { it.chapterId == page.chapterId && it.id == page.id } if (index != -1) { withContext(Dispatchers.Main) { readerManager.currentReader?.switchPageTo(index, true) } + } else { + viewModel.switchChapter(page.chapterId, page.index) } } } diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderSliderListener.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderSliderListener.kt index bd959969d..50e87a4d9 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderSliderListener.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderSliderListener.kt @@ -1,6 +1,7 @@ package org.koitharu.kotatsu.reader.ui import com.google.android.material.slider.Slider +import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import org.koitharu.kotatsu.reader.ui.thumbnails.OnPageSelectListener class ReaderSliderListener( @@ -41,6 +42,7 @@ class ReaderSliderListener( private fun switchPageToIndex(index: Int) { val pages = viewModel.getCurrentChapterPages() val page = pages?.getOrNull(index) ?: return - pageSelectListener.onPageSelected(page) + val chapterId = viewModel.getCurrentState()?.chapterId ?: return + pageSelectListener.onPageSelected(ReaderPage(page, index, chapterId)) } } diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt index da7f04e5b..c4d6bdc5b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt @@ -237,13 +237,13 @@ class ReaderViewModel @Inject constructor( }?.toMangaPage() } - fun switchChapter(id: Long) { + fun switchChapter(id: Long, page: Int) { val prevJob = loadingJob loadingJob = launchLoadingJob(Dispatchers.Default) { prevJob?.cancelAndJoin() content.postValue(ReaderContent(emptyList(), null)) chaptersLoader.loadSingleChapter(mangaData.requireValue(), id) - content.postValue(ReaderContent(chaptersLoader.snapshot(), ReaderState(id, 0, 0))) + content.postValue(ReaderContent(chaptersLoader.snapshot(), ReaderState(id, page, 0))) } } diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/MangaPageFetcher.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/MangaPageFetcher.kt new file mode 100644 index 000000000..3246d0a99 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/MangaPageFetcher.kt @@ -0,0 +1,112 @@ +package org.koitharu.kotatsu.reader.ui.thumbnails + +import android.content.Context +import androidx.core.net.toUri +import coil.ImageLoader +import coil.decode.DataSource +import coil.decode.ImageSource +import coil.fetch.FetchResult +import coil.fetch.Fetcher +import coil.fetch.SourceResult +import coil.request.Options +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runInterruptible +import okhttp3.OkHttpClient +import okio.Path.Companion.toOkioPath +import okio.buffer +import okio.source +import org.koitharu.kotatsu.core.parser.MangaRepository +import org.koitharu.kotatsu.local.data.CbzFilter +import org.koitharu.kotatsu.local.data.PagesCache +import org.koitharu.kotatsu.local.data.util.withExtraCloseable +import org.koitharu.kotatsu.parsers.model.MangaPage +import org.koitharu.kotatsu.parsers.util.await +import org.koitharu.kotatsu.parsers.util.mimeType +import org.koitharu.kotatsu.reader.domain.PageLoader +import java.util.zip.ZipFile + +class MangaPageFetcher( + private val context: Context, + private val okHttpClient: OkHttpClient, + private val pagesCache: PagesCache, + private val options: Options, + private val page: MangaPage, + private val mangaRepositoryFactory: MangaRepository.Factory, +) : Fetcher { + + override suspend fun fetch(): FetchResult { + val repo = mangaRepositoryFactory.create(page.source) + val pageUrl = repo.getPageUrl(page) + pagesCache.get(pageUrl)?.let { file -> + return SourceResult( + source = ImageSource( + file = file.toOkioPath(), + metadata = MangaPageMetadata(page), + ), + mimeType = null, + dataSource = DataSource.DISK, + ) + } + return loadPage(pageUrl) + } + + private suspend fun loadPage(pageUrl: String): SourceResult { + val uri = pageUrl.toUri() + return if (CbzFilter.isUriSupported(uri)) { + val zip = runInterruptible(Dispatchers.IO) { ZipFile(uri.schemeSpecificPart) } + val entry = runInterruptible(Dispatchers.IO) { zip.getEntry(uri.fragment) } + return SourceResult( + source = ImageSource( + source = zip.getInputStream(entry).source().withExtraCloseable(zip).buffer(), + context = context, + metadata = MangaPageMetadata(page), + ), + mimeType = null, + dataSource = DataSource.DISK, + ) + } else { + val request = PageLoader.createPageRequest(page, pageUrl) + okHttpClient.newCall(request).await().use { response -> + check(response.isSuccessful) { + "Invalid response: ${response.code} ${response.message} at $pageUrl" + } + val body = checkNotNull(response.body) { + "Null response" + } + val mimeType = response.mimeType + val file = body.use { + pagesCache.put(pageUrl, it.source()) + } + SourceResult( + source = ImageSource( + file = file.toOkioPath(), + metadata = MangaPageMetadata(page), + ), + mimeType = mimeType, + dataSource = DataSource.NETWORK, + ) + } + } + } + + class Factory( + private val context: Context, + private val okHttpClient: OkHttpClient, + private val pagesCache: PagesCache, + private val mangaRepositoryFactory: MangaRepository.Factory, + ) : Fetcher.Factory { + + override fun create(data: MangaPage, options: Options, imageLoader: ImageLoader): Fetcher { + return MangaPageFetcher( + okHttpClient = okHttpClient, + pagesCache = pagesCache, + options = options, + page = data, + context = context, + mangaRepositoryFactory = mangaRepositoryFactory, + ) + } + } + + class MangaPageMetadata(val page: MangaPage) : ImageSource.Metadata() +} diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/OnPageSelectListener.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/OnPageSelectListener.kt index 38db30b5e..3b6281c64 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/OnPageSelectListener.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/OnPageSelectListener.kt @@ -1,8 +1,8 @@ package org.koitharu.kotatsu.reader.ui.thumbnails -import org.koitharu.kotatsu.parsers.model.MangaPage +import org.koitharu.kotatsu.reader.ui.pager.ReaderPage fun interface OnPageSelectListener { - fun onPageSelected(page: MangaPage) -} \ No newline at end of file + fun onPageSelected(page: ReaderPage) +} diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/PageThumbnail.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/PageThumbnail.kt index 22c5ddad5..f413b9615 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/PageThumbnail.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/PageThumbnail.kt @@ -1,11 +1,34 @@ package org.koitharu.kotatsu.reader.ui.thumbnails import org.koitharu.kotatsu.core.parser.MangaRepository -import org.koitharu.kotatsu.parsers.model.MangaPage +import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.reader.ui.pager.ReaderPage -data class PageThumbnail( - val number: Int, +class PageThumbnail( val isCurrent: Boolean, val repository: MangaRepository, - val page: MangaPage -) \ No newline at end of file + val page: ReaderPage, +) : ListModel { + + val number + get() = page.index + 1 + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as PageThumbnail + + if (isCurrent != other.isCurrent) return false + if (repository != other.repository) return false + return page == other.page + } + + override fun hashCode(): Int { + var result = isCurrent.hashCode() + result = 31 * result + repository.hashCode() + result = 31 * result + page.hashCode() + return result + } + +} diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/PagesThumbnailsSheet.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/PagesThumbnailsSheet.kt index 0d9ca8b22..80aee22e9 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/PagesThumbnailsSheet.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/PagesThumbnailsSheet.kt @@ -5,38 +5,40 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.FragmentManager +import androidx.fragment.app.viewModels import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView import coil.ImageLoader import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BaseBottomSheet +import org.koitharu.kotatsu.base.ui.list.BoundsScrollListener import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.base.ui.list.ScrollListenerInvalidationObserver import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration import org.koitharu.kotatsu.base.ui.widgets.BottomSheetHeaderBar -import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaPages -import org.koitharu.kotatsu.core.parser.MangaRepository +import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver +import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.databinding.SheetPagesBinding import org.koitharu.kotatsu.list.ui.MangaListSpanResolver -import org.koitharu.kotatsu.parsers.model.MangaPage -import org.koitharu.kotatsu.reader.domain.PageLoader +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.reader.ui.ReaderActivity +import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.thumbnails.adapter.PageThumbnailAdapter -import org.koitharu.kotatsu.utils.ext.getParcelableCompat -import org.koitharu.kotatsu.utils.ext.viewLifecycleScope +import org.koitharu.kotatsu.reader.ui.thumbnails.adapter.TargetScrollObserver +import org.koitharu.kotatsu.utils.LoggingAdapterDataObserver +import org.koitharu.kotatsu.utils.ext.scaleUpActivityOptionsOf import org.koitharu.kotatsu.utils.ext.withArgs import javax.inject.Inject @AndroidEntryPoint class PagesThumbnailsSheet : BaseBottomSheet(), - OnListItemClickListener, + OnListItemClickListener, BottomSheetHeaderBar.OnExpansionChangeListener { - @Inject - lateinit var mangaRepositoryFactory: MangaRepository.Factory - - @Inject - lateinit var pageLoader: PageLoader + private val viewModel by viewModels() @Inject lateinit var coil: ImageLoader @@ -44,27 +46,13 @@ class PagesThumbnailsSheet : @Inject lateinit var settings: AppSettings - private lateinit var thumbnails: List + private var thumbnailsAdapter: PageThumbnailAdapter? = null private var spanResolver: MangaListSpanResolver? = null - private var currentPageIndex = -1 + private var scrollListener: ScrollListener? = null - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val pages = arguments?.getParcelableCompat(ARG_PAGES)?.pages - if (pages.isNullOrEmpty()) { - dismissAllowingStateLoss() - return - } - currentPageIndex = requireArguments().getInt(ARG_CURRENT, currentPageIndex) - val repository = mangaRepositoryFactory.create(pages.first().source) - thumbnails = pages.mapIndexed { i, x -> - PageThumbnail( - number = i + 1, - isCurrent = i == currentPageIndex, - repository = repository, - page = x, - ) - } + private val spanSizeLookup = SpanSizeLookup() + private val listCommitCallback = Runnable { + spanSizeLookup.invalidateCache() } override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): SheetPagesBinding { @@ -73,74 +61,116 @@ class PagesThumbnailsSheet : override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - spanResolver = MangaListSpanResolver(view.resources) with(binding.headerBar) { - title = arguments?.getString(ARG_TITLE) + title = viewModel.title subtitle = null addOnExpansionChangeListener(this@PagesThumbnailsSheet) } - + thumbnailsAdapter = PageThumbnailAdapter( + coil = coil, + lifecycleOwner = viewLifecycleOwner, + clickListener = this@PagesThumbnailsSheet, + ) with(binding.recyclerView) { addItemDecoration( SpacingItemDecoration(resources.getDimensionPixelOffset(R.dimen.grid_spacing)), ) - adapter = PageThumbnailAdapter( - dataSet = thumbnails, - coil = coil, - scope = viewLifecycleScope, - loader = pageLoader, - clickListener = this@PagesThumbnailsSheet, - ) + adapter = thumbnailsAdapter addOnLayoutChangeListener(spanResolver) spanResolver?.setGridSize(settings.gridSize / 100f, this) - if (currentPageIndex > 0) { - val offset = resources.getDimensionPixelOffset(R.dimen.preferred_grid_width) - (layoutManager as GridLayoutManager).scrollToPositionWithOffset(currentPageIndex, offset) - } + addOnScrollListener(ScrollListener().also { scrollListener = it }) + (layoutManager as GridLayoutManager).spanSizeLookup = spanSizeLookup + thumbnailsAdapter?.registerAdapterDataObserver( + ScrollListenerInvalidationObserver(this, checkNotNull(scrollListener)), + ) + thumbnailsAdapter?.registerAdapterDataObserver(TargetScrollObserver(this)) + thumbnailsAdapter?.registerAdapterDataObserver(LoggingAdapterDataObserver("THUMB")) } + viewModel.thumbnails.observe(viewLifecycleOwner) { + thumbnailsAdapter?.setItems(it, listCommitCallback) + } + viewModel.branch.observe(viewLifecycleOwner) { + onExpansionStateChanged(binding.headerBar, binding.headerBar.isExpanded) + } + viewModel.onError.observe(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this)) } override fun onDestroyView() { - super.onDestroyView() spanResolver = null + scrollListener = null + thumbnailsAdapter = null + spanSizeLookup.invalidateCache() + super.onDestroyView() } - override fun onItemClick(item: MangaPage, view: View) { - ( - (parentFragment as? OnPageSelectListener) - ?: (activity as? OnPageSelectListener) - )?.run { - onPageSelected(item) - dismiss() - } + override fun onItemClick(item: PageThumbnail, view: View) { + val listener = (parentFragment as? OnPageSelectListener) ?: (activity as? OnPageSelectListener) + if (listener != null) { + listener.onPageSelected(item.page) + } else { + val state = ReaderState(item.page.chapterId, item.page.index, 0) + val intent = ReaderActivity.newIntent(view.context, viewModel.manga, state) + startActivity(intent, scaleUpActivityOptionsOf(view).toBundle()) + } + dismiss() } override fun onExpansionStateChanged(headerBar: BottomSheetHeaderBar, isExpanded: Boolean) { if (isExpanded) { - headerBar.subtitle = resources.getQuantityString( - R.plurals.pages, - thumbnails.size, - thumbnails.size, - ) + headerBar.subtitle = viewModel.branch.value } else { headerBar.subtitle = null } } + private inner class ScrollListener : BoundsScrollListener(3, 3) { + + override fun onScrolledToStart(recyclerView: RecyclerView) { + viewModel.loadPrevChapter() + } + + override fun onScrolledToEnd(recyclerView: RecyclerView) { + viewModel.loadNextChapter() + } + } + + private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup() { + + init { + isSpanIndexCacheEnabled = true + isSpanGroupIndexCacheEnabled = true + } + + override fun getSpanSize(position: Int): Int { + val total = + (binding.recyclerView.layoutManager as? GridLayoutManager)?.spanCount ?: return 1 + return when (thumbnailsAdapter?.getItemViewType(position)) { + PageThumbnailAdapter.ITEM_TYPE_THUMBNAIL -> 1 + else -> total + } + } + + fun invalidateCache() { + invalidateSpanGroupIndexCache() + invalidateSpanIndexCache() + } + } + companion object { - private const val ARG_PAGES = "pages" - private const val ARG_TITLE = "title" - private const val ARG_CURRENT = "current" + const val ARG_MANGA = "manga" + const val ARG_CURRENT_PAGE = "current" + const val ARG_CHAPTER_ID = "chapter_id" private const val TAG = "PagesThumbnailsSheet" - fun show(fm: FragmentManager, pages: List, title: String, currentPage: Int) = + fun show(fm: FragmentManager, manga: Manga, chapterId: Long, currentPage: Int = -1) { PagesThumbnailsSheet().withArgs(3) { - putParcelable(ARG_PAGES, ParcelableMangaPages(pages)) - putString(ARG_TITLE, title) - putInt(ARG_CURRENT, currentPage) + putParcelable(ARG_MANGA, ParcelableManga(manga, true)) + putLong(ARG_CHAPTER_ID, chapterId) + putInt(ARG_CURRENT_PAGE, currentPage) }.show(fm, TAG) + } } } diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/PagesThumbnailsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/PagesThumbnailsViewModel.kt new file mode 100644 index 000000000..00401578b --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/PagesThumbnailsViewModel.kt @@ -0,0 +1,107 @@ +package org.koitharu.kotatsu.reader.ui.thumbnails + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.SavedStateHandle +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import org.koitharu.kotatsu.base.ui.BaseViewModel +import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga +import org.koitharu.kotatsu.core.parser.MangaRepository +import org.koitharu.kotatsu.list.ui.model.ListHeader +import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.list.ui.model.LoadingFooter +import org.koitharu.kotatsu.parsers.util.SuspendLazy +import org.koitharu.kotatsu.reader.data.filterChapters +import org.koitharu.kotatsu.reader.domain.ChaptersLoader +import org.koitharu.kotatsu.utils.ext.emitValue +import javax.inject.Inject + +@HiltViewModel +class PagesThumbnailsViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + mangaRepositoryFactory: MangaRepository.Factory, + private val chaptersLoader: ChaptersLoader, +) : BaseViewModel() { + + private val currentPageIndex: Int = savedStateHandle[PagesThumbnailsSheet.ARG_CURRENT_PAGE] ?: -1 + private val initialChapterId: Long = savedStateHandle[PagesThumbnailsSheet.ARG_CHAPTER_ID] ?: 0L + val manga = requireNotNull(savedStateHandle.get(PagesThumbnailsSheet.ARG_MANGA)).manga + + private val repository = mangaRepositoryFactory.create(manga.source) + private val mangaDetails = SuspendLazy { + repository.getDetails(manga).let { + chaptersLoader.chapters.clear() + val b = manga.chapters?.find { ch -> ch.id == initialChapterId }?.branch + branch.emitValue(b) + it.getChapters(b)?.forEach { ch -> + chaptersLoader.chapters.put(ch.id, ch) + } + it.filterChapters(b) + } + } + private var loadingJob: Job? = null + private var loadingPrevJob: Job? = null + private var loadingNextJob: Job? = null + + val thumbnails = MutableLiveData>() + val branch = MutableLiveData() + val title = manga.title + + init { + loadingJob = launchJob(Dispatchers.Default) { + chaptersLoader.loadSingleChapter(mangaDetails.get(), initialChapterId) + updateList() + } + } + + fun loadPrevChapter() { + if (loadingJob?.isActive == true || loadingPrevJob?.isActive == true) { + return + } + loadingPrevJob = loadPrevNextChapter(isNext = false) + } + + fun loadNextChapter() { + if (loadingJob?.isActive == true || loadingNextJob?.isActive == true) { + return + } + loadingNextJob = loadPrevNextChapter(isNext = true) + } + + private fun loadPrevNextChapter(isNext: Boolean): Job = launchLoadingJob(Dispatchers.Default) { + val currentId = (if (isNext) chaptersLoader.last() else chaptersLoader.first()).chapterId + chaptersLoader.loadPrevNextChapter(mangaDetails.get(), currentId, isNext) + updateList() + } + + private suspend fun updateList() { + val snapshot = chaptersLoader.snapshot() + val mangaChapters = mangaDetails.tryGet().getOrNull()?.chapters.orEmpty() + val hasPrevChapter = snapshot.firstOrNull()?.chapterId != mangaChapters.firstOrNull()?.id + val hasNextChapter = snapshot.lastOrNull()?.chapterId != mangaChapters.lastOrNull()?.id + val pages = buildList(snapshot.size + chaptersLoader.chapters.size() + 2) { + if (hasPrevChapter) { + add(LoadingFooter(-1)) + } + var previousChapterId = 0L + for (page in snapshot) { + if (page.chapterId != previousChapterId) { + chaptersLoader.chapters[page.chapterId]?.let { + add(ListHeader(it.name, 0, null)) + } + previousChapterId = page.chapterId + } + this += PageThumbnail( + isCurrent = page.chapterId == initialChapterId && page.index == currentPageIndex, + repository = repository, + page = page, + ) + } + if (hasNextChapter) { + add(LoadingFooter(1)) + } + } + thumbnails.emitValue(pages) + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/PageThumbnailAD.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/PageThumbnailAD.kt index 7d2c6c3bd..3ec91573f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/PageThumbnailAD.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/PageThumbnailAD.kt @@ -1,91 +1,63 @@ package org.koitharu.kotatsu.reader.ui.thumbnails.adapter -import android.graphics.drawable.Drawable +import androidx.lifecycle.LifecycleOwner import coil.ImageLoader -import coil.request.ImageRequest import coil.size.Scale import coil.size.Size import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.databinding.ItemPageThumbBinding -import org.koitharu.kotatsu.parsers.model.MangaPage -import org.koitharu.kotatsu.reader.domain.PageLoader +import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail import org.koitharu.kotatsu.utils.ext.decodeRegion -import org.koitharu.kotatsu.utils.ext.isLowRamDevice -import org.koitharu.kotatsu.utils.ext.runCatchingCancellable +import org.koitharu.kotatsu.utils.ext.disposeImageRequest +import org.koitharu.kotatsu.utils.ext.enqueueWith +import org.koitharu.kotatsu.utils.ext.newImageRequest import org.koitharu.kotatsu.utils.ext.setTextColorAttr +import org.koitharu.kotatsu.utils.ext.source import com.google.android.material.R as materialR fun pageThumbnailAD( coil: ImageLoader, - scope: CoroutineScope, - loader: PageLoader, - clickListener: OnListItemClickListener, -) = adapterDelegateViewBinding( + lifecycleOwner: LifecycleOwner, + clickListener: OnListItemClickListener, +) = adapterDelegateViewBinding( { inflater, parent -> ItemPageThumbBinding.inflate(inflater, parent, false) }, ) { - var job: Job? = null + val gridWidth = itemView.context.resources.getDimensionPixelSize(R.dimen.preferred_grid_width) val thumbSize = Size( width = gridWidth, height = (gridWidth / 13f * 18f).toInt(), ) - suspend fun loadPageThumbnail(item: PageThumbnail): Drawable? = withContext(Dispatchers.Default) { - item.page.preview?.let { url -> - coil.execute( - ImageRequest.Builder(context) - .data(url) - .tag(item.page.source) - .size(thumbSize) - .scale(Scale.FILL) - .allowRgb565(true) - .build(), - ).drawable - }?.let { drawable -> - return@withContext drawable - } - val file = loader.loadPage(item.page, force = false) - coil.execute( - ImageRequest.Builder(context) - .data(file) - .size(thumbSize) - .decodeRegion(0) - .allowRgb565(isLowRamDevice(context)) - .build(), - ).drawable - } - - binding.root.setOnClickListener { - clickListener.onItemClick(item.page, itemView) - } + val clickListenerAdapter = AdapterDelegateClickListenerAdapter(this, clickListener) + binding.root.setOnClickListener(clickListenerAdapter) + binding.root.setOnLongClickListener(clickListenerAdapter) bind { - job?.cancel() - binding.imageViewThumb.setImageDrawable(null) + val data: Any = item.page.preview?.takeUnless { it.isEmpty() } ?: item.page.toMangaPage() + binding.imageViewThumb.newImageRequest(lifecycleOwner, data)?.run { + placeholder(R.drawable.ic_placeholder) + fallback(R.drawable.ic_placeholder) + error(R.drawable.ic_error_placeholder) + size(thumbSize) + scale(Scale.FILL) + allowRgb565(true) + decodeRegion(0) + source(item.page.source) + enqueueWith(coil) + } with(binding.textViewNumber) { setBackgroundResource(if (item.isCurrent) R.drawable.bg_badge_accent else R.drawable.bg_badge_empty) setTextColorAttr(if (item.isCurrent) materialR.attr.colorOnTertiary else android.R.attr.textColorPrimary) text = (item.number).toString() } - job = scope.launch { - val drawable = runCatchingCancellable { - loadPageThumbnail(item) - }.getOrNull() - binding.imageViewThumb.setImageDrawable(drawable) - } } onViewRecycled { - job?.cancel() - job = null - binding.imageViewThumb.setImageDrawable(null) + binding.imageViewThumb.disposeImageRequest() } } diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/PageThumbnailAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/PageThumbnailAdapter.kt index b293d2865..4fffe12e3 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/PageThumbnailAdapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/PageThumbnailAdapter.kt @@ -1,23 +1,60 @@ package org.koitharu.kotatsu.reader.ui.thumbnails.adapter +import androidx.lifecycle.LifecycleOwner +import androidx.recyclerview.widget.DiffUtil import coil.ImageLoader -import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter -import kotlinx.coroutines.CoroutineScope +import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.parsers.model.MangaPage -import org.koitharu.kotatsu.reader.domain.PageLoader +import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD +import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD +import org.koitharu.kotatsu.list.ui.model.ListHeader +import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.list.ui.model.LoadingFooter import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail class PageThumbnailAdapter( - dataSet: List, coil: ImageLoader, - scope: CoroutineScope, - loader: PageLoader, - clickListener: OnListItemClickListener -) : ListDelegationAdapter>() { + lifecycleOwner: LifecycleOwner, + clickListener: OnListItemClickListener, +) : AsyncListDifferDelegationAdapter(DiffCallback()) { init { - delegatesManager.addDelegate(pageThumbnailAD(coil, scope, loader, clickListener)) - setItems(dataSet) + delegatesManager.addDelegate(ITEM_TYPE_THUMBNAIL, pageThumbnailAD(coil, lifecycleOwner, clickListener)) + .addDelegate(ITEM_TYPE_HEADER, listHeaderAD(null)) + .addDelegate(ITEM_LOADING, loadingFooterAD()) } -} \ No newline at end of file + + private class DiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: ListModel, newItem: ListModel): Boolean { + return when { + oldItem is PageThumbnail && newItem is PageThumbnail -> { + oldItem.page == newItem.page + } + + oldItem is ListHeader && newItem is ListHeader -> { + oldItem.textRes == newItem.textRes && + oldItem.text == newItem.text && + oldItem.dateTimeAgo == newItem.dateTimeAgo + } + + oldItem is LoadingFooter && newItem is LoadingFooter -> { + oldItem.key == newItem.key + } + + else -> oldItem.javaClass == newItem.javaClass + } + } + + override fun areContentsTheSame(oldItem: ListModel, newItem: ListModel): Boolean { + return oldItem == newItem + } + } + + companion object { + + const val ITEM_TYPE_THUMBNAIL = 0 + const val ITEM_TYPE_HEADER = 1 + const val ITEM_LOADING = 2 + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/TargetScrollObserver.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/TargetScrollObserver.kt new file mode 100644 index 000000000..bc27e4a01 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/TargetScrollObserver.kt @@ -0,0 +1,41 @@ +package org.koitharu.kotatsu.reader.ui.thumbnails.adapter + +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter +import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail + +class TargetScrollObserver( + private val recyclerView: RecyclerView, +) : RecyclerView.AdapterDataObserver() { + + private var isScrollToCurrentPending = true + + private val layoutManager: LinearLayoutManager + get() = recyclerView.layoutManager as LinearLayoutManager + + override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { + if (isScrollToCurrentPending) { + postScroll() + } + } + + private fun postScroll() { + recyclerView.post { + scrollToTarget() + } + } + + private fun scrollToTarget() { + val adapter = recyclerView.adapter ?: return + if (recyclerView.computeVerticalScrollRange() == 0) { + return + } + val snapshot = (adapter as? AsyncListDifferDelegationAdapter<*>)?.items ?: return + val target = snapshot.indexOfFirst { it is PageThumbnail && it.isCurrent } + if (target in snapshot.indices) { + layoutManager.scrollToPositionWithOffset(target, 0) + isScrollToCurrentPending = false + } + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt index 0f2f18880..6601551db 100644 --- a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt @@ -85,7 +85,7 @@ class RemoteListViewModel @Inject constructor( list.toUi(this, mode, tagHighlighter) when { error != null -> add(error.toErrorFooter()) - hasNext -> add(LoadingFooter) + hasNext -> add(LoadingFooter()) } } } diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorViewModel.kt index de9c2898c..495a68883 100644 --- a/app/src/main/java/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorViewModel.kt @@ -58,7 +58,7 @@ class ScrobblingSelectorViewModel @Inject constructor( ) { list, error, isHasNextPage -> if (list.isNotEmpty()) { if (isHasNextPage) { - list + LoadingFooter + list + LoadingFooter() } else { list } @@ -66,7 +66,7 @@ class ScrobblingSelectorViewModel @Inject constructor( listOf( when { error != null -> errorHint(error) - isHasNextPage -> LoadingFooter + isHasNextPage -> LoadingFooter() else -> emptyResultsHint() }, ) diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/common/ui/selector/adapter/ScrobblerSelectorAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/common/ui/selector/adapter/ScrobblerSelectorAdapter.kt index e3d7af6c6..9ed58c8e4 100644 --- a/app/src/main/java/org/koitharu/kotatsu/scrobbling/common/ui/selector/adapter/ScrobblerSelectorAdapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/common/ui/selector/adapter/ScrobblerSelectorAdapter.kt @@ -9,6 +9,7 @@ import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.list.ui.model.LoadingFooter import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga import org.koitharu.kotatsu.scrobbling.common.ui.selector.model.ScrobblerHint import kotlin.jvm.internal.Intrinsics @@ -34,6 +35,7 @@ class ScrobblerSelectorAdapter( oldItem === newItem -> true oldItem is ScrobblerManga && newItem is ScrobblerManga -> oldItem.id == newItem.id oldItem is ScrobblerHint && newItem is ScrobblerHint -> oldItem.textPrimary == newItem.textPrimary + oldItem is LoadingFooter && newItem is LoadingFooter -> oldItem.key == newItem.key else -> false } } diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchViewModel.kt index 8ce2d3d90..f6e1571f7 100644 --- a/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchViewModel.kt @@ -65,7 +65,7 @@ class SearchViewModel @Inject constructor( list.toUi(result, mode, tagHighlighter) when { error != null -> result += error.toErrorFooter() - hasNext -> result += LoadingFooter + hasNext -> result += LoadingFooter() } result } diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt index 72e6f777d..83c99f126 100644 --- a/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt @@ -72,7 +72,7 @@ class MultiSearchViewModel @Inject constructor( }, ) - loading -> list + LoadingFooter + loading -> list + LoadingFooter() else -> list } }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/adapter/MultiSearchAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/adapter/MultiSearchAdapter.kt index 179957661..05ff6f427 100644 --- a/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/adapter/MultiSearchAdapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/adapter/MultiSearchAdapter.kt @@ -14,6 +14,7 @@ import org.koitharu.kotatsu.list.ui.adapter.errorStateListAD import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.list.ui.model.LoadingFooter import org.koitharu.kotatsu.search.ui.multi.MultiSearchListModel import kotlin.jvm.internal.Intrinsics @@ -54,6 +55,10 @@ class MultiSearchAdapter( oldItem.source == newItem.source } + oldItem is LoadingFooter && newItem is LoadingFooter -> { + oldItem.key == newItem.key + } + else -> oldItem.javaClass == newItem.javaClass } } diff --git a/app/src/main/java/org/koitharu/kotatsu/shelf/ui/adapter/ShelfAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/shelf/ui/adapter/ShelfAdapter.kt index e96a9735f..0043cd052 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shelf/ui/adapter/ShelfAdapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/shelf/ui/adapter/ShelfAdapter.kt @@ -16,6 +16,7 @@ import org.koitharu.kotatsu.list.ui.adapter.errorStateListAD import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.list.ui.model.LoadingFooter import org.koitharu.kotatsu.shelf.ui.model.ShelfSectionModel import kotlin.jvm.internal.Intrinsics @@ -62,6 +63,10 @@ class ShelfAdapter( oldItem.key == newItem.key } + oldItem is LoadingFooter && newItem is LoadingFooter -> { + oldItem.key == newItem.key + } + else -> oldItem.javaClass == newItem.javaClass } } diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/feed/adapter/FeedAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/feed/adapter/FeedAdapter.kt index b35158c6e..a0ddea045 100644 --- a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/feed/adapter/FeedAdapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/feed/adapter/FeedAdapter.kt @@ -13,6 +13,7 @@ import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD import org.koitharu.kotatsu.list.ui.adapter.relatedDateItemAD import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.list.ui.model.LoadingFooter import org.koitharu.kotatsu.tracker.ui.feed.model.FeedItem import kotlin.jvm.internal.Intrinsics @@ -44,6 +45,10 @@ class FeedAdapter( oldItem == newItem } + oldItem is LoadingFooter && newItem is LoadingFooter -> { + oldItem.key == newItem.key + } + else -> oldItem.javaClass == newItem.javaClass } diff --git a/app/src/main/res/menu/popup_read.xml b/app/src/main/res/menu/popup_read.xml index 7feb8133b..256f2ef54 100644 --- a/app/src/main/res/menu/popup_read.xml +++ b/app/src/main/res/menu/popup_read.xml @@ -6,4 +6,10 @@ android:id="@+id/action_incognito" android:icon="@drawable/ic_incognito" android:title="@string/incognito_mode" /> + + +