Improve pages thumbnails sheet

pull/375/head
Koitharu 3 years ago
parent 43a92bdf08
commit 3f96f34b8e
No known key found for this signature in database
GPG Key ID: 8E861F8CE6E7CE27

@ -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()")
}
}

@ -70,6 +70,9 @@ class BottomSheetHeaderBar @JvmOverloads constructor(
binding.toolbar.subtitle = value binding.toolbar.subtitle = value
} }
val isExpanded: Boolean
get() = binding.dragHandle.isGone
init { init {
setBackgroundResource(R.drawable.sheet_toolbar_background) setBackgroundResource(R.drawable.sheet_toolbar_background)
layoutTransition = LayoutTransition().apply { layoutTransition = LayoutTransition().apply {

@ -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.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import kotlin.jvm.internal.Intrinsics import kotlin.jvm.internal.Intrinsics
@ -54,6 +55,10 @@ class BookmarksGroupAdapter(
oldItem.manga.id == newItem.manga.id oldItem.manga.id == newItem.manga.id
} }
oldItem is LoadingFooter && newItem is LoadingFooter -> {
oldItem.key == newItem.key
}
else -> oldItem.javaClass == newItem.javaClass else -> oldItem.javaClass == newItem.javaClass
} }
} }

@ -46,8 +46,10 @@ import org.koitharu.kotatsu.local.data.CbzFetcher
import org.koitharu.kotatsu.local.data.LocalManga import org.koitharu.kotatsu.local.data.LocalManga
import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.data.LocalStorageManager 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.main.ui.protect.AppProtectHelper
import org.koitharu.kotatsu.parsers.MangaLoaderContext 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.search.ui.MangaSuggestionsProvider
import org.koitharu.kotatsu.settings.backup.BackupObserver import org.koitharu.kotatsu.settings.backup.BackupObserver
import org.koitharu.kotatsu.sync.domain.SyncController import org.koitharu.kotatsu.sync.domain.SyncController
@ -136,6 +138,7 @@ interface AppModule {
@ApplicationContext context: Context, @ApplicationContext context: Context,
okHttpClient: OkHttpClient, okHttpClient: OkHttpClient,
mangaRepositoryFactory: MangaRepository.Factory, mangaRepositoryFactory: MangaRepository.Factory,
pagesCache: PagesCache,
): ImageLoader { ): ImageLoader {
val httpClientFactory = { val httpClientFactory = {
okHttpClient.newBuilder() okHttpClient.newBuilder()
@ -162,6 +165,7 @@ interface AppModule {
.add(SvgDecoder.Factory()) .add(SvgDecoder.Factory())
.add(CbzFetcher.Factory()) .add(CbzFetcher.Factory())
.add(FaviconFetcher.Factory(context, okHttpClient, mangaRepositoryFactory)) .add(FaviconFetcher.Factory(context, okHttpClient, mangaRepositoryFactory))
.add(MangaPageFetcher.Factory(context, okHttpClient, pagesCache, mangaRepositoryFactory))
.build(), .build(),
).build() ).build()
} }

@ -43,6 +43,7 @@ import org.koitharu.kotatsu.main.ui.owners.NoModalBottomSheetOwner
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState 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.ViewBadge
import org.koitharu.kotatsu.utils.ext.setNavigationBarTransparentCompat import org.koitharu.kotatsu.utils.ext.setNavigationBarTransparentCompat
import org.koitharu.kotatsu.utils.ext.textAndVisible import org.koitharu.kotatsu.utils.ext.textAndVisible
@ -158,14 +159,29 @@ class DetailsActivity :
else -> false else -> false
} }
override fun onMenuItemClick(item: MenuItem): Boolean = when (item.itemId) { override fun onMenuItemClick(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_incognito -> { R.id.action_incognito -> {
openReader(isIncognitoMode = true) openReader(isIncognitoMode = true)
true true
} }
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 else -> false
} }
}
override fun onExpansionStateChanged(headerBar: BottomSheetHeaderBar, isExpanded: Boolean) { override fun onExpansionStateChanged(headerBar: BottomSheetHeaderBar, isExpanded: Boolean) {
if (isExpanded) { if (isExpanded) {

@ -7,13 +7,15 @@ import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.utils.ext.setTextAndVisible import org.koitharu.kotatsu.utils.ext.setTextAndVisible
fun listHeaderAD( fun listHeaderAD(
listener: ListHeaderClickListener, listener: ListHeaderClickListener?,
) = adapterDelegateViewBinding<ListHeader, ListModel, ItemHeaderButtonBinding>( ) = adapterDelegateViewBinding<ListHeader, ListModel, ItemHeaderButtonBinding>(
{ inflater, parent -> ItemHeaderButtonBinding.inflate(inflater, parent, false) }, { inflater, parent -> ItemHeaderButtonBinding.inflate(inflater, parent, false) },
) { ) {
if (listener != null) {
binding.buttonMore.setOnClickListener { binding.buttonMore.setOnClickListener {
listener.onListHeaderClick(item, it) listener.onListHeaderClick(item, it)
} }
}
bind { bind {
binding.textViewTitle.text = item.getText(context) binding.textViewTitle.text = item.getText(context)

@ -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.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListHeader2 import org.koitharu.kotatsu.list.ui.model.ListHeader2
import org.koitharu.kotatsu.list.ui.model.ListModel 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.MangaGridModel
import org.koitharu.kotatsu.list.ui.model.MangaItemModel import org.koitharu.kotatsu.list.ui.model.MangaItemModel
import org.koitharu.kotatsu.list.ui.model.MangaListDetailedModel import org.koitharu.kotatsu.list.ui.model.MangaListDetailedModel
@ -60,6 +61,10 @@ open class MangaListAdapter(
oldItem.dateTimeAgo == newItem.dateTimeAgo oldItem.dateTimeAgo == newItem.dateTimeAgo
} }
oldItem is LoadingFooter && newItem is LoadingFooter -> {
oldItem.key == newItem.key
}
else -> oldItem.javaClass == newItem.javaClass else -> oldItem.javaClass == newItem.javaClass
} }

@ -1,6 +1,19 @@
package org.koitharu.kotatsu.list.ui.model 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
}
} }

@ -1,5 +1,6 @@
package org.koitharu.kotatsu.local.data package org.koitharu.kotatsu.local.data
import android.net.Uri
import java.io.File import java.io.File
import java.io.FileFilter import java.io.FileFilter
import java.io.FilenameFilter import java.io.FilenameFilter
@ -21,5 +22,10 @@ class CbzFilter : FileFilter, FilenameFilter {
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
return ext == "cbz" || ext == "zip" return ext == "cbz" || ext == "zip"
} }
fun isUriSupported(uri: Uri): Boolean {
val scheme = uri.scheme?.lowercase(Locale.ROOT)
return scheme != null && scheme == "cbz" || scheme == "zip"
}
} }
} }

@ -63,6 +63,10 @@ class ChaptersLoader @Inject constructor(
return chapterPages.size(chapterId) return chapterPages.size(chapterId)
} }
fun last() = chapterPages.last()
fun first() = chapterPages.first()
fun snapshot() = chapterPages.toList() fun snapshot() = chapterPages.toList()
private suspend fun loadChapter(manga: Manga, chapterId: Long): List<ReaderPage> { private suspend fun loadChapter(manga: Manga, chapterId: Long): List<ReaderPage> {

@ -25,6 +25,7 @@ import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings 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.local.data.PagesCache
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
@ -42,9 +43,6 @@ import javax.inject.Inject
import kotlin.coroutines.AbstractCoroutineContextElement import kotlin.coroutines.AbstractCoroutineContextElement
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
private const val PROGRESS_UNDEFINED = -1f
private const val PREFETCH_LIMIT_DEFAULT = 10
@ActivityRetainedScoped @ActivityRetainedScoped
class PageLoader @Inject constructor( class PageLoader @Inject constructor(
lifecycle: ActivityRetainedLifecycle, lifecycle: ActivityRetainedLifecycle,
@ -179,7 +177,7 @@ class PageLoader @Inject constructor(
val pageUrl = getPageUrl(page) val pageUrl = getPageUrl(page)
check(pageUrl.isNotBlank()) { "Cannot obtain full image url" } check(pageUrl.isNotBlank()) { "Cannot obtain full image url" }
val uri = Uri.parse(pageUrl) val uri = Uri.parse(pageUrl)
return if (uri.scheme == "cbz") { return if (CbzFilter.isUriSupported(uri)) {
runInterruptible(Dispatchers.IO) { runInterruptible(Dispatchers.IO) {
ZipFile(uri.schemeSpecificPart) ZipFile(uri.schemeSpecificPart)
}.use { zip -> }.use { zip ->
@ -191,13 +189,7 @@ class PageLoader @Inject constructor(
} }
} }
} else { } else {
val request = Request.Builder() val request = createPageRequest(page, pageUrl)
.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()
okHttp.newCall(request).await().use { response -> okHttp.newCall(request).await().use { response ->
check(response.isSuccessful) { check(response.isSuccessful) {
"Invalid response: ${response.code} ${response.message} at $pageUrl" "Invalid response: ${response.code} ${response.message} at $pageUrl"
@ -218,6 +210,19 @@ class PageLoader @Inject constructor(
override fun handleException(context: CoroutineContext, exception: Throwable) { override fun handleException(context: CoroutineContext, exception: Throwable) {
exception.printStackTraceDebug() 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()
} }
} }

@ -40,8 +40,8 @@ import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.databinding.ActivityReaderBinding import org.koitharu.kotatsu.databinding.ActivityReaderBinding
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter 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.config.ReaderConfigBottomSheet
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
import org.koitharu.kotatsu.reader.ui.thumbnails.OnPageSelectListener import org.koitharu.kotatsu.reader.ui.thumbnails.OnPageSelectListener
import org.koitharu.kotatsu.reader.ui.thumbnails.PagesThumbnailsSheet import org.koitharu.kotatsu.reader.ui.thumbnails.PagesThumbnailsSheet
@ -180,17 +180,13 @@ class ReaderActivity :
} }
R.id.action_pages_thumbs -> { R.id.action_pages_thumbs -> {
val pages = viewModel.getCurrentChapterPages() val state = viewModel.getCurrentState() ?: return false
if (!pages.isNullOrEmpty()) {
PagesThumbnailsSheet.show( PagesThumbnailsSheet.show(
supportFragmentManager, supportFragmentManager,
pages, viewModel.manga ?: return false,
title?.toString().orEmpty(), state.chapterId,
readerManager.currentReader?.getCurrentState()?.page ?: -1, state.page,
) )
} else {
return false
}
} }
R.id.action_bookmark -> { R.id.action_bookmark -> {
@ -259,17 +255,19 @@ class ReaderActivity :
} }
override fun onChapterChanged(chapter: MangaChapter) { 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) { lifecycleScope.launch(Dispatchers.Default) {
val pages = viewModel.content.value?.pages ?: return@launch 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) { if (index != -1) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
readerManager.currentReader?.switchPageTo(index, true) readerManager.currentReader?.switchPageTo(index, true)
} }
} else {
viewModel.switchChapter(page.chapterId, page.index)
} }
} }
} }

@ -1,6 +1,7 @@
package org.koitharu.kotatsu.reader.ui package org.koitharu.kotatsu.reader.ui
import com.google.android.material.slider.Slider import com.google.android.material.slider.Slider
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.reader.ui.thumbnails.OnPageSelectListener import org.koitharu.kotatsu.reader.ui.thumbnails.OnPageSelectListener
class ReaderSliderListener( class ReaderSliderListener(
@ -41,6 +42,7 @@ class ReaderSliderListener(
private fun switchPageToIndex(index: Int) { private fun switchPageToIndex(index: Int) {
val pages = viewModel.getCurrentChapterPages() val pages = viewModel.getCurrentChapterPages()
val page = pages?.getOrNull(index) ?: return val page = pages?.getOrNull(index) ?: return
pageSelectListener.onPageSelected(page) val chapterId = viewModel.getCurrentState()?.chapterId ?: return
pageSelectListener.onPageSelected(ReaderPage(page, index, chapterId))
} }
} }

@ -237,13 +237,13 @@ class ReaderViewModel @Inject constructor(
}?.toMangaPage() }?.toMangaPage()
} }
fun switchChapter(id: Long) { fun switchChapter(id: Long, page: Int) {
val prevJob = loadingJob val prevJob = loadingJob
loadingJob = launchLoadingJob(Dispatchers.Default) { loadingJob = launchLoadingJob(Dispatchers.Default) {
prevJob?.cancelAndJoin() prevJob?.cancelAndJoin()
content.postValue(ReaderContent(emptyList(), null)) content.postValue(ReaderContent(emptyList(), null))
chaptersLoader.loadSingleChapter(mangaData.requireValue(), id) chaptersLoader.loadSingleChapter(mangaData.requireValue(), id)
content.postValue(ReaderContent(chaptersLoader.snapshot(), ReaderState(id, 0, 0))) content.postValue(ReaderContent(chaptersLoader.snapshot(), ReaderState(id, page, 0)))
} }
} }

@ -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<MangaPage> {
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()
}

@ -1,8 +1,8 @@
package org.koitharu.kotatsu.reader.ui.thumbnails 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 interface OnPageSelectListener {
fun onPageSelected(page: MangaPage) fun onPageSelected(page: ReaderPage)
} }

@ -1,11 +1,34 @@
package org.koitharu.kotatsu.reader.ui.thumbnails package org.koitharu.kotatsu.reader.ui.thumbnails
import org.koitharu.kotatsu.core.parser.MangaRepository 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( class PageThumbnail(
val number: Int,
val isCurrent: Boolean, val isCurrent: Boolean,
val repository: MangaRepository, val repository: MangaRepository,
val page: MangaPage 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
}
}

@ -5,38 +5,40 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseBottomSheet 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.OnListItemClickListener
import org.koitharu.kotatsu.base.ui.list.ScrollListenerInvalidationObserver
import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.base.ui.widgets.BottomSheetHeaderBar import org.koitharu.kotatsu.base.ui.widgets.BottomSheetHeaderBar
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaPages import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.databinding.SheetPagesBinding import org.koitharu.kotatsu.databinding.SheetPagesBinding
import org.koitharu.kotatsu.list.ui.MangaListSpanResolver import org.koitharu.kotatsu.list.ui.MangaListSpanResolver
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.reader.domain.PageLoader 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.reader.ui.thumbnails.adapter.PageThumbnailAdapter
import org.koitharu.kotatsu.utils.ext.getParcelableCompat import org.koitharu.kotatsu.reader.ui.thumbnails.adapter.TargetScrollObserver
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope import org.koitharu.kotatsu.utils.LoggingAdapterDataObserver
import org.koitharu.kotatsu.utils.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.utils.ext.withArgs import org.koitharu.kotatsu.utils.ext.withArgs
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class PagesThumbnailsSheet : class PagesThumbnailsSheet :
BaseBottomSheet<SheetPagesBinding>(), BaseBottomSheet<SheetPagesBinding>(),
OnListItemClickListener<MangaPage>, OnListItemClickListener<PageThumbnail>,
BottomSheetHeaderBar.OnExpansionChangeListener { BottomSheetHeaderBar.OnExpansionChangeListener {
@Inject private val viewModel by viewModels<PagesThumbnailsViewModel>()
lateinit var mangaRepositoryFactory: MangaRepository.Factory
@Inject
lateinit var pageLoader: PageLoader
@Inject @Inject
lateinit var coil: ImageLoader lateinit var coil: ImageLoader
@ -44,27 +46,13 @@ class PagesThumbnailsSheet :
@Inject @Inject
lateinit var settings: AppSettings lateinit var settings: AppSettings
private lateinit var thumbnails: List<PageThumbnail> private var thumbnailsAdapter: PageThumbnailAdapter? = null
private var spanResolver: MangaListSpanResolver? = null private var spanResolver: MangaListSpanResolver? = null
private var currentPageIndex = -1 private var scrollListener: ScrollListener? = null
override fun onCreate(savedInstanceState: Bundle?) { private val spanSizeLookup = SpanSizeLookup()
super.onCreate(savedInstanceState) private val listCommitCallback = Runnable {
val pages = arguments?.getParcelableCompat<ParcelableMangaPages>(ARG_PAGES)?.pages spanSizeLookup.invalidateCache()
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,
)
}
} }
override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): SheetPagesBinding { override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): SheetPagesBinding {
@ -73,74 +61,116 @@ class PagesThumbnailsSheet :
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
spanResolver = MangaListSpanResolver(view.resources) spanResolver = MangaListSpanResolver(view.resources)
with(binding.headerBar) { with(binding.headerBar) {
title = arguments?.getString(ARG_TITLE) title = viewModel.title
subtitle = null subtitle = null
addOnExpansionChangeListener(this@PagesThumbnailsSheet) addOnExpansionChangeListener(this@PagesThumbnailsSheet)
} }
thumbnailsAdapter = PageThumbnailAdapter(
coil = coil,
lifecycleOwner = viewLifecycleOwner,
clickListener = this@PagesThumbnailsSheet,
)
with(binding.recyclerView) { with(binding.recyclerView) {
addItemDecoration( addItemDecoration(
SpacingItemDecoration(resources.getDimensionPixelOffset(R.dimen.grid_spacing)), SpacingItemDecoration(resources.getDimensionPixelOffset(R.dimen.grid_spacing)),
) )
adapter = PageThumbnailAdapter( adapter = thumbnailsAdapter
dataSet = thumbnails,
coil = coil,
scope = viewLifecycleScope,
loader = pageLoader,
clickListener = this@PagesThumbnailsSheet,
)
addOnLayoutChangeListener(spanResolver) addOnLayoutChangeListener(spanResolver)
spanResolver?.setGridSize(settings.gridSize / 100f, this) spanResolver?.setGridSize(settings.gridSize / 100f, this)
if (currentPageIndex > 0) { addOnScrollListener(ScrollListener().also { scrollListener = it })
val offset = resources.getDimensionPixelOffset(R.dimen.preferred_grid_width) (layoutManager as GridLayoutManager).spanSizeLookup = spanSizeLookup
(layoutManager as GridLayoutManager).scrollToPositionWithOffset(currentPageIndex, offset) 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() { override fun onDestroyView() {
super.onDestroyView()
spanResolver = null spanResolver = null
scrollListener = null
thumbnailsAdapter = null
spanSizeLookup.invalidateCache()
super.onDestroyView()
} }
override fun onItemClick(item: MangaPage, view: View) { override fun onItemClick(item: PageThumbnail, view: View) {
( val listener = (parentFragment as? OnPageSelectListener) ?: (activity as? OnPageSelectListener)
(parentFragment as? OnPageSelectListener) if (listener != null) {
?: (activity as? OnPageSelectListener) listener.onPageSelected(item.page)
)?.run { } else {
onPageSelected(item) val state = ReaderState(item.page.chapterId, item.page.index, 0)
dismiss() val intent = ReaderActivity.newIntent(view.context, viewModel.manga, state)
startActivity(intent, scaleUpActivityOptionsOf(view).toBundle())
} }
dismiss()
} }
override fun onExpansionStateChanged(headerBar: BottomSheetHeaderBar, isExpanded: Boolean) { override fun onExpansionStateChanged(headerBar: BottomSheetHeaderBar, isExpanded: Boolean) {
if (isExpanded) { if (isExpanded) {
headerBar.subtitle = resources.getQuantityString( headerBar.subtitle = viewModel.branch.value
R.plurals.pages,
thumbnails.size,
thumbnails.size,
)
} else { } else {
headerBar.subtitle = null 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 { companion object {
private const val ARG_PAGES = "pages" const val ARG_MANGA = "manga"
private const val ARG_TITLE = "title" const val ARG_CURRENT_PAGE = "current"
private const val ARG_CURRENT = "current" const val ARG_CHAPTER_ID = "chapter_id"
private const val TAG = "PagesThumbnailsSheet" private const val TAG = "PagesThumbnailsSheet"
fun show(fm: FragmentManager, pages: List<MangaPage>, title: String, currentPage: Int) = fun show(fm: FragmentManager, manga: Manga, chapterId: Long, currentPage: Int = -1) {
PagesThumbnailsSheet().withArgs(3) { PagesThumbnailsSheet().withArgs(3) {
putParcelable(ARG_PAGES, ParcelableMangaPages(pages)) putParcelable(ARG_MANGA, ParcelableManga(manga, true))
putString(ARG_TITLE, title) putLong(ARG_CHAPTER_ID, chapterId)
putInt(ARG_CURRENT, currentPage) putInt(ARG_CURRENT_PAGE, currentPage)
}.show(fm, TAG) }.show(fm, TAG)
} }
}
} }

@ -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<ParcelableManga>(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<List<ListModel>>()
val branch = MutableLiveData<String?>()
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)
}
}

@ -1,91 +1,63 @@
package org.koitharu.kotatsu.reader.ui.thumbnails.adapter package org.koitharu.kotatsu.reader.ui.thumbnails.adapter
import android.graphics.drawable.Drawable import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader import coil.ImageLoader
import coil.request.ImageRequest
import coil.size.Scale import coil.size.Scale
import coil.size.Size import coil.size.Size
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding 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.R
import org.koitharu.kotatsu.base.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.databinding.ItemPageThumbBinding import org.koitharu.kotatsu.databinding.ItemPageThumbBinding
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail
import org.koitharu.kotatsu.utils.ext.decodeRegion import org.koitharu.kotatsu.utils.ext.decodeRegion
import org.koitharu.kotatsu.utils.ext.isLowRamDevice import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable 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.setTextColorAttr
import org.koitharu.kotatsu.utils.ext.source
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
fun pageThumbnailAD( fun pageThumbnailAD(
coil: ImageLoader, coil: ImageLoader,
scope: CoroutineScope, lifecycleOwner: LifecycleOwner,
loader: PageLoader, clickListener: OnListItemClickListener<PageThumbnail>,
clickListener: OnListItemClickListener<MangaPage>, ) = adapterDelegateViewBinding<PageThumbnail, ListModel, ItemPageThumbBinding>(
) = adapterDelegateViewBinding<PageThumbnail, PageThumbnail, ItemPageThumbBinding>(
{ inflater, parent -> ItemPageThumbBinding.inflate(inflater, parent, false) }, { inflater, parent -> ItemPageThumbBinding.inflate(inflater, parent, false) },
) { ) {
var job: Job? = null
val gridWidth = itemView.context.resources.getDimensionPixelSize(R.dimen.preferred_grid_width) val gridWidth = itemView.context.resources.getDimensionPixelSize(R.dimen.preferred_grid_width)
val thumbSize = Size( val thumbSize = Size(
width = gridWidth, width = gridWidth,
height = (gridWidth / 13f * 18f).toInt(), height = (gridWidth / 13f * 18f).toInt(),
) )
suspend fun loadPageThumbnail(item: PageThumbnail): Drawable? = withContext(Dispatchers.Default) { val clickListenerAdapter = AdapterDelegateClickListenerAdapter(this, clickListener)
item.page.preview?.let { url -> binding.root.setOnClickListener(clickListenerAdapter)
coil.execute( binding.root.setOnLongClickListener(clickListenerAdapter)
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)
}
bind { bind {
job?.cancel() val data: Any = item.page.preview?.takeUnless { it.isEmpty() } ?: item.page.toMangaPage()
binding.imageViewThumb.setImageDrawable(null) 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) { with(binding.textViewNumber) {
setBackgroundResource(if (item.isCurrent) R.drawable.bg_badge_accent else R.drawable.bg_badge_empty) 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) setTextColorAttr(if (item.isCurrent) materialR.attr.colorOnTertiary else android.R.attr.textColorPrimary)
text = (item.number).toString() text = (item.number).toString()
} }
job = scope.launch {
val drawable = runCatchingCancellable {
loadPageThumbnail(item)
}.getOrNull()
binding.imageViewThumb.setImageDrawable(drawable)
}
} }
onViewRecycled { onViewRecycled {
job?.cancel() binding.imageViewThumb.disposeImageRequest()
job = null
binding.imageViewThumb.setImageDrawable(null)
} }
} }

@ -1,23 +1,60 @@
package org.koitharu.kotatsu.reader.ui.thumbnails.adapter package org.koitharu.kotatsu.reader.ui.thumbnails.adapter
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DiffUtil
import coil.ImageLoader import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import kotlinx.coroutines.CoroutineScope
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD
import org.koitharu.kotatsu.reader.domain.PageLoader 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 import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail
class PageThumbnailAdapter( class PageThumbnailAdapter(
dataSet: List<PageThumbnail>,
coil: ImageLoader, coil: ImageLoader,
scope: CoroutineScope, lifecycleOwner: LifecycleOwner,
loader: PageLoader, clickListener: OnListItemClickListener<PageThumbnail>,
clickListener: OnListItemClickListener<MangaPage> ) : AsyncListDifferDelegationAdapter<ListModel>(DiffCallback()) {
) : ListDelegationAdapter<List<PageThumbnail>>() {
init { init {
delegatesManager.addDelegate(pageThumbnailAD(coil, scope, loader, clickListener)) delegatesManager.addDelegate(ITEM_TYPE_THUMBNAIL, pageThumbnailAD(coil, lifecycleOwner, clickListener))
setItems(dataSet) .addDelegate(ITEM_TYPE_HEADER, listHeaderAD(null))
.addDelegate(ITEM_LOADING, loadingFooterAD())
}
private class DiffCallback : DiffUtil.ItemCallback<ListModel>() {
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
} }
} }

@ -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
}
}
}

@ -85,7 +85,7 @@ class RemoteListViewModel @Inject constructor(
list.toUi(this, mode, tagHighlighter) list.toUi(this, mode, tagHighlighter)
when { when {
error != null -> add(error.toErrorFooter()) error != null -> add(error.toErrorFooter())
hasNext -> add(LoadingFooter) hasNext -> add(LoadingFooter())
} }
} }
} }

@ -58,7 +58,7 @@ class ScrobblingSelectorViewModel @Inject constructor(
) { list, error, isHasNextPage -> ) { list, error, isHasNextPage ->
if (list.isNotEmpty()) { if (list.isNotEmpty()) {
if (isHasNextPage) { if (isHasNextPage) {
list + LoadingFooter list + LoadingFooter()
} else { } else {
list list
} }
@ -66,7 +66,7 @@ class ScrobblingSelectorViewModel @Inject constructor(
listOf( listOf(
when { when {
error != null -> errorHint(error) error != null -> errorHint(error)
isHasNextPage -> LoadingFooter isHasNextPage -> LoadingFooter()
else -> emptyResultsHint() else -> emptyResultsHint()
}, },
) )

@ -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.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.model.ListModel 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.domain.model.ScrobblerManga
import org.koitharu.kotatsu.scrobbling.common.ui.selector.model.ScrobblerHint import org.koitharu.kotatsu.scrobbling.common.ui.selector.model.ScrobblerHint
import kotlin.jvm.internal.Intrinsics import kotlin.jvm.internal.Intrinsics
@ -34,6 +35,7 @@ class ScrobblerSelectorAdapter(
oldItem === newItem -> true oldItem === newItem -> true
oldItem is ScrobblerManga && newItem is ScrobblerManga -> oldItem.id == newItem.id oldItem is ScrobblerManga && newItem is ScrobblerManga -> oldItem.id == newItem.id
oldItem is ScrobblerHint && newItem is ScrobblerHint -> oldItem.textPrimary == newItem.textPrimary oldItem is ScrobblerHint && newItem is ScrobblerHint -> oldItem.textPrimary == newItem.textPrimary
oldItem is LoadingFooter && newItem is LoadingFooter -> oldItem.key == newItem.key
else -> false else -> false
} }
} }

@ -65,7 +65,7 @@ class SearchViewModel @Inject constructor(
list.toUi(result, mode, tagHighlighter) list.toUi(result, mode, tagHighlighter)
when { when {
error != null -> result += error.toErrorFooter() error != null -> result += error.toErrorFooter()
hasNext -> result += LoadingFooter hasNext -> result += LoadingFooter()
} }
result result
} }

@ -72,7 +72,7 @@ class MultiSearchViewModel @Inject constructor(
}, },
) )
loading -> list + LoadingFooter loading -> list + LoadingFooter()
else -> list else -> list
} }
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))

@ -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.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.model.ListModel 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 org.koitharu.kotatsu.search.ui.multi.MultiSearchListModel
import kotlin.jvm.internal.Intrinsics import kotlin.jvm.internal.Intrinsics
@ -54,6 +55,10 @@ class MultiSearchAdapter(
oldItem.source == newItem.source oldItem.source == newItem.source
} }
oldItem is LoadingFooter && newItem is LoadingFooter -> {
oldItem.key == newItem.key
}
else -> oldItem.javaClass == newItem.javaClass else -> oldItem.javaClass == newItem.javaClass
} }
} }

@ -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.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.model.ListModel 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 org.koitharu.kotatsu.shelf.ui.model.ShelfSectionModel
import kotlin.jvm.internal.Intrinsics import kotlin.jvm.internal.Intrinsics
@ -62,6 +63,10 @@ class ShelfAdapter(
oldItem.key == newItem.key oldItem.key == newItem.key
} }
oldItem is LoadingFooter && newItem is LoadingFooter -> {
oldItem.key == newItem.key
}
else -> oldItem.javaClass == newItem.javaClass else -> oldItem.javaClass == newItem.javaClass
} }
} }

@ -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.loadingStateAD
import org.koitharu.kotatsu.list.ui.adapter.relatedDateItemAD import org.koitharu.kotatsu.list.ui.adapter.relatedDateItemAD
import org.koitharu.kotatsu.list.ui.model.ListModel 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 org.koitharu.kotatsu.tracker.ui.feed.model.FeedItem
import kotlin.jvm.internal.Intrinsics import kotlin.jvm.internal.Intrinsics
@ -44,6 +45,10 @@ class FeedAdapter(
oldItem == newItem oldItem == newItem
} }
oldItem is LoadingFooter && newItem is LoadingFooter -> {
oldItem.key == newItem.key
}
else -> oldItem.javaClass == newItem.javaClass else -> oldItem.javaClass == newItem.javaClass
} }

@ -6,4 +6,10 @@
android:id="@+id/action_incognito" android:id="@+id/action_incognito"
android:icon="@drawable/ic_incognito" android:icon="@drawable/ic_incognito"
android:title="@string/incognito_mode" /> android:title="@string/incognito_mode" />
<item
android:id="@+id/action_pages_thumbs"
android:icon="@drawable/ic_grid"
android:title="@string/pages" />
</menu> </menu>

Loading…
Cancel
Save