Merge branch 'feature/thumbnails' into devel
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<ListHeader, ListModel, ItemHeaderButtonBinding>(
|
||||
{ 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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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.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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
||||
|
||||
fun interface OnPageSelectListener {
|
||||
|
||||
fun onPageSelected(page: MangaPage)
|
||||
}
|
||||
fun onPageSelected(page: ReaderPage)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
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.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<SheetPagesBinding>(),
|
||||
OnListItemClickListener<MangaPage>,
|
||||
OnListItemClickListener<PageThumbnail>,
|
||||
BottomSheetHeaderBar.OnExpansionChangeListener {
|
||||
|
||||
@Inject
|
||||
lateinit var mangaRepositoryFactory: MangaRepository.Factory
|
||||
|
||||
@Inject
|
||||
lateinit var pageLoader: PageLoader
|
||||
private val viewModel by viewModels<PagesThumbnailsViewModel>()
|
||||
|
||||
@Inject
|
||||
lateinit var coil: ImageLoader
|
||||
@@ -44,27 +46,13 @@ class PagesThumbnailsSheet :
|
||||
@Inject
|
||||
lateinit var settings: AppSettings
|
||||
|
||||
private lateinit var thumbnails: List<PageThumbnail>
|
||||
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<ParcelableMangaPages>(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<MangaPage>, 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
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<MangaPage>,
|
||||
) = adapterDelegateViewBinding<PageThumbnail, PageThumbnail, ItemPageThumbBinding>(
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
clickListener: OnListItemClickListener<PageThumbnail>,
|
||||
) = adapterDelegateViewBinding<PageThumbnail, ListModel, ItemPageThumbBinding>(
|
||||
{ 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PageThumbnail>,
|
||||
coil: ImageLoader,
|
||||
scope: CoroutineScope,
|
||||
loader: PageLoader,
|
||||
clickListener: OnListItemClickListener<MangaPage>
|
||||
) : ListDelegationAdapter<List<PageThumbnail>>() {
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
clickListener: OnListItemClickListener<PageThumbnail>,
|
||||
) : AsyncListDifferDelegationAdapter<ListModel>(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())
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
when {
|
||||
error != null -> add(error.toErrorFooter())
|
||||
hasNext -> add(LoadingFooter)
|
||||
hasNext -> add(LoadingFooter())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ class MultiSearchViewModel @Inject constructor(
|
||||
},
|
||||
)
|
||||
|
||||
loading -> list + LoadingFooter
|
||||
loading -> list + LoadingFooter()
|
||||
else -> list
|
||||
}
|
||||
}.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.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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -6,4 +6,10 @@
|
||||
android:id="@+id/action_incognito"
|
||||
android:icon="@drawable/ic_incognito"
|
||||
android:title="@string/incognito_mode" />
|
||||
|
||||
<item
|
||||
android:id="@+id/action_pages_thumbs"
|
||||
android:icon="@drawable/ic_grid"
|
||||
android:title="@string/pages" />
|
||||
|
||||
</menu>
|
||||
|
||||
Reference in New Issue
Block a user