diff --git a/app/build.gradle b/app/build.gradle index 403e8e5ae..7e1c0fdbc 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -82,7 +82,7 @@ afterEvaluate { } dependencies { //noinspection GradleDependency - implementation('com.github.KotatsuApp:kotatsu-parsers:b274b51699') { + implementation('com.github.KotatsuApp:kotatsu-parsers:7c89f53988') { exclude group: 'org.json', module: 'json' } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ChaptersBottomSheetMediator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ChaptersBottomSheetMediator.kt index f74d65393..83f6a38b1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ChaptersBottomSheetMediator.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ChaptersBottomSheetMediator.kt @@ -6,12 +6,14 @@ import android.view.View import android.view.View.OnLayoutChangeListener import androidx.activity.OnBackPressedCallback import androidx.appcompat.view.ActionMode +import androidx.viewpager2.widget.ViewPager2 import com.google.android.material.bottomsheet.BottomSheetBehavior import org.koitharu.kotatsu.core.ui.util.ActionModeListener import org.koitharu.kotatsu.core.util.ext.doOnExpansionsChanged class ChaptersBottomSheetMediator( private val behavior: BottomSheetBehavior<*>, + private val pager: ViewPager2, ) : OnBackPressedCallback(false), ActionModeListener, OnLayoutChangeListener, View.OnGenericMotionListener { @@ -74,6 +76,7 @@ class ChaptersBottomSheetMediator( fun lock() { lockCounter++ behavior.isDraggable = lockCounter <= 0 + pager.isUserInputEnabled = lockCounter <= 0 } fun unlock() { @@ -82,5 +85,6 @@ class ChaptersBottomSheetMediator( lockCounter = 0 } behavior.isDraggable = lockCounter <= 0 + pager.isUserInputEnabled = lockCounter <= 0 } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt index c4cc3ebd7..07cbbec6b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt @@ -29,6 +29,7 @@ import androidx.core.view.updatePadding import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.snackbar.BaseTransientBottomBar import com.google.android.material.snackbar.Snackbar +import com.google.android.material.tabs.TabLayoutMediator import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.filterNotNull @@ -46,6 +47,7 @@ import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled import org.koitharu.kotatsu.core.util.ext.measureHeight import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent +import org.koitharu.kotatsu.core.util.ext.recyclerView import org.koitharu.kotatsu.core.util.ext.setNavigationBarTransparentCompat import org.koitharu.kotatsu.core.util.ext.setNavigationIconSafe import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat @@ -54,6 +56,7 @@ import org.koitharu.kotatsu.databinding.ActivityDetailsBinding import org.koitharu.kotatsu.details.service.MangaPrefetchService import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.HistoryInfo +import org.koitharu.kotatsu.details.ui.pager.DetailsPagerAdapter import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver import org.koitharu.kotatsu.main.ui.owners.NoModalBottomSheetOwner import org.koitharu.kotatsu.parsers.model.Manga @@ -93,7 +96,7 @@ class DetailsActivity : if (viewBinding.layoutBottom != null) { val behavior = BottomSheetBehavior.from(checkNotNull(viewBinding.layoutBottom)) - val bsMediator = ChaptersBottomSheetMediator(behavior) + val bsMediator = ChaptersBottomSheetMediator(behavior, viewBinding.pager) actionModeDelegate.addListener(bsMediator) checkNotNull(viewBinding.layoutBsHeader).addOnLayoutChangeListener(bsMediator) onBackPressedDispatcher.addCallback(bsMediator) @@ -108,9 +111,11 @@ class DetailsActivity : addMenuProvider(chaptersMenuProvider) } onBackPressedDispatcher.addCallback(chaptersMenuProvider) + initPager() viewModel.manga.filterNotNull().observe(this, ::onMangaUpdated) viewModel.onMangaRemoved.observeEvent(this, ::onMangaRemoved) + viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged) viewModel.onError.observeEvent( this, SnackbarErrorObserver( @@ -153,7 +158,7 @@ class DetailsActivity : DetailsMenuProvider( activity = this, viewModel = viewModel, - snackbarHost = viewBinding.containerChapters, + snackbarHost = viewBinding.pager, appShortcutManager = appShortcutManager, ), ) @@ -293,6 +298,18 @@ class DetailsActivity : viewBinding.textViewTitle?.text = text } + private fun onNewChaptersChanged(count: Int) { + val tab = viewBinding.tabs.getTabAt(0) ?: return + if (count == 0) { + tab.removeBadge() + } else { + val badge = tab.orCreateBadge + badge.horizontalOffset = resources.getDimensionPixelOffset(R.dimen.margin_small) + badge.number = count + badge.isVisible = true + } + } + private fun showBranchPopupMenu(v: View) { val menu = PopupMenu(v.context, v) val branches = viewModel.branches.value @@ -343,6 +360,13 @@ class DetailsActivity : } } + private fun initPager() { + viewBinding.pager.recyclerView?.isNestedScrollingEnabled = false + val adapter = DetailsPagerAdapter(this) + viewBinding.pager.adapter = adapter + TabLayoutMediator(viewBinding.tabs, viewBinding.pager, adapter).attach() + } + private fun showBottomSheet(isVisible: Boolean) { val view = viewBinding.layoutBottom ?: return if (view.isVisible == isVisible) return diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/DetailsPagerAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/DetailsPagerAdapter.kt new file mode 100644 index 000000000..7e7d84c26 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/DetailsPagerAdapter.kt @@ -0,0 +1,32 @@ +package org.koitharu.kotatsu.details.ui.pager + +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.viewpager2.adapter.FragmentStateAdapter +import com.google.android.material.tabs.TabLayout +import com.google.android.material.tabs.TabLayoutMediator +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.details.ui.ChaptersFragment +import org.koitharu.kotatsu.details.ui.pager.pages.PagesFragment + +class DetailsPagerAdapter(activity: FragmentActivity) : FragmentStateAdapter(activity), + TabLayoutMediator.TabConfigurationStrategy { + + override fun getItemCount(): Int = 2 + + override fun createFragment(position: Int): Fragment = when (position) { + 0 -> ChaptersFragment() + 1 -> PagesFragment() + else -> throw IllegalArgumentException("Invalid position $position") + } + + override fun onConfigureTab(tab: TabLayout.Tab, position: Int) { + tab.setText( + when (position) { + 0 -> R.string.chapters + 1 -> R.string.pages + else -> 0 + }, + ) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesFragment.kt new file mode 100644 index 000000000..338abd8d4 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesFragment.kt @@ -0,0 +1,177 @@ +package org.koitharu.kotatsu.details.ui.pager.pages + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.graphics.Insets +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView +import coil.ImageLoader +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.combine +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.ui.BaseFragment +import org.koitharu.kotatsu.core.ui.list.BoundsScrollListener +import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback +import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.core.util.ext.observeEvent +import org.koitharu.kotatsu.core.util.ext.plus +import org.koitharu.kotatsu.core.util.ext.showOrHide +import org.koitharu.kotatsu.databinding.FragmentPagesBinding +import org.koitharu.kotatsu.details.ui.DetailsViewModel +import org.koitharu.kotatsu.list.ui.MangaListSpanResolver +import org.koitharu.kotatsu.list.ui.adapter.ListItemType +import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration +import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder +import org.koitharu.kotatsu.reader.ui.ReaderState +import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail +import org.koitharu.kotatsu.reader.ui.thumbnails.adapter.PageThumbnailAdapter +import javax.inject.Inject +import kotlin.math.roundToInt + +@AndroidEntryPoint +class PagesFragment : + BaseFragment(), + OnListItemClickListener { + + private val detailsViewModel by activityViewModels() + private val viewModel by viewModels() + + @Inject + lateinit var coil: ImageLoader + + @Inject + lateinit var settings: AppSettings + + private var thumbnailsAdapter: PageThumbnailAdapter? = null + private var spanResolver: MangaListSpanResolver? = null + private var scrollListener: ScrollListener? = null + + private val spanSizeLookup = SpanSizeLookup() + private val listCommitCallback = Runnable { + spanSizeLookup.invalidateCache() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + combine( + detailsViewModel.details, + detailsViewModel.history, + detailsViewModel.selectedBranch, + ) { details, history, branch -> + if (details?.isLoaded == true) { + PagesViewModel.State(details, history, branch) + } else { + null + } + }.observe(this, viewModel::updateState) + } + + override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentPagesBinding { + return FragmentPagesBinding.inflate(inflater, container, false) + } + + override fun onViewBindingCreated(binding: FragmentPagesBinding, savedInstanceState: Bundle?) { + super.onViewBindingCreated(binding, savedInstanceState) + spanResolver = MangaListSpanResolver(binding.root.resources) + thumbnailsAdapter = PageThumbnailAdapter( + coil = coil, + lifecycleOwner = viewLifecycleOwner, + clickListener = this@PagesFragment, + ) + with(binding.recyclerView) { + addItemDecoration(TypedListSpacingDecoration(context, false)) + adapter = thumbnailsAdapter + addOnLayoutChangeListener(spanResolver) + spanResolver?.setGridSize(settings.gridSize / 100f, this) + addOnScrollListener(ScrollListener().also { scrollListener = it }) + (layoutManager as GridLayoutManager).let { + it.spanSizeLookup = spanSizeLookup + it.spanCount = checkNotNull(spanResolver).spanCount + } + } + viewModel.thumbnails.observe(viewLifecycleOwner, ::onThumbnailsChanged) + viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this)) + viewModel.isLoading.observe(viewLifecycleOwner) { binding.progressBar.showOrHide(it) } + viewModel.isLoadingUp.observe(viewLifecycleOwner) { binding.progressBarTop.showOrHide(it) } + viewModel.isLoadingDown.observe(viewLifecycleOwner) { binding.progressBarBottom.showOrHide(it) } + } + + override fun onDestroyView() { + spanResolver = null + scrollListener = null + thumbnailsAdapter = null + spanSizeLookup.invalidateCache() + super.onDestroyView() + } + + override fun onWindowInsetsChanged(insets: Insets) = Unit + + override fun onItemClick(item: PageThumbnail, view: View) { + val manga = detailsViewModel.manga.value ?: return + val state = ReaderState(item.page.chapterId, item.page.index, 0) + val intent = IntentBuilder(view.context).manga(manga).state(state).build() + startActivity(intent) + } + + private fun onThumbnailsChanged(list: List) { + val adapter = thumbnailsAdapter ?: return + if (adapter.itemCount == 0) { + var position = list.indexOfFirst { it is PageThumbnail && it.isCurrent } + if (position > 0) { + val spanCount = spanResolver?.spanCount ?: 0 + val offset = if (position > spanCount + 1) { + (resources.getDimensionPixelSize(R.dimen.manga_list_details_item_height) * 0.6).roundToInt() + } else { + position = 0 + 0 + } + val scrollCallback = RecyclerViewScrollCallback(requireViewBinding().recyclerView, position, offset) + adapter.setItems(list, listCommitCallback + scrollCallback) + } else { + adapter.setItems(list, listCommitCallback) + } + } else { + adapter.setItems(list, listCommitCallback) + } + } + + 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 = (viewBinding?.recyclerView?.layoutManager as? GridLayoutManager)?.spanCount ?: return 1 + return when (thumbnailsAdapter?.getItemViewType(position)) { + ListItemType.PAGE_THUMB.ordinal -> 1 + else -> total + } + } + + fun invalidateCache() { + invalidateSpanGroupIndexCache() + invalidateSpanIndexCache() + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesViewModel.kt new file mode 100644 index 000000000..8fc24eb53 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesViewModel.kt @@ -0,0 +1,113 @@ +package org.koitharu.kotatsu.details.ui.pager.pages + +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest +import org.koitharu.kotatsu.core.model.MangaHistory +import org.koitharu.kotatsu.core.ui.BaseViewModel +import org.koitharu.kotatsu.core.util.ext.firstNotNull +import org.koitharu.kotatsu.details.data.MangaDetails +import org.koitharu.kotatsu.list.ui.model.ListHeader +import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.reader.domain.ChaptersLoader +import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail +import javax.inject.Inject + +@HiltViewModel +class PagesViewModel @Inject constructor( + private val chaptersLoader: ChaptersLoader, +) : BaseViewModel() { + + private var loadingJob: Job? = null + private var loadingPrevJob: Job? = null + private var loadingNextJob: Job? = null + + private val state = MutableStateFlow(null) + val thumbnails = MutableStateFlow>(emptyList()) + val isLoadingUp = MutableStateFlow(false) + val isLoadingDown = MutableStateFlow(false) + + init { + loadingJob = launchLoadingJob(Dispatchers.Default) { + val firstState = state.firstNotNull() + doInit(firstState) + launchJob(Dispatchers.Default) { + state.collectLatest { + if (it != null) { + doInit(it) + } + } + } + } + } + + fun updateState(newState: State?) { + if (newState != null) { + state.value = newState + } + } + + 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 suspend fun doInit(state: State) { + chaptersLoader.init(state.details) + val initialChapterId = state.history?.chapterId ?: state.details.allChapters.firstOrNull()?.id ?: return + chaptersLoader.loadSingleChapter(initialChapterId) + updateList(state.history) + } + + private fun loadPrevNextChapter(isNext: Boolean): Job = launchJob(Dispatchers.Default) { + val indicator = if (isNext) isLoadingDown else isLoadingUp + indicator.value = true + try { + val currentState = state.firstNotNull() + val currentId = (if (isNext) chaptersLoader.last() else chaptersLoader.first()).chapterId + chaptersLoader.loadPrevNextChapter(currentState.details, currentId, isNext) + updateList(currentState.history) + } finally { + indicator.value = false + } + } + + private fun updateList(history: MangaHistory?) { + val snapshot = chaptersLoader.snapshot() + val pages = buildList(snapshot.size + chaptersLoader.size + 2) { + var previousChapterId = 0L + for (page in snapshot) { + if (page.chapterId != previousChapterId) { + chaptersLoader.peekChapter(page.chapterId)?.let { + add(ListHeader(it.name)) + } + previousChapterId = page.chapterId + } + this += PageThumbnail( + isCurrent = history?.let { + page.chapterId == it.chapterId && page.index == it.page + } ?: false, + page = page, + ) + } + } + thumbnails.value = pages + } + + data class State( + val details: MangaDetails, + val history: MangaHistory?, + val branch: String? + ) +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/PageThumbnail.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/PageThumbnail.kt index 294b8bb2e..a0efac812 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/PageThumbnail.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/PageThumbnail.kt @@ -1,12 +1,10 @@ package org.koitharu.kotatsu.reader.ui.thumbnails -import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.reader.ui.pager.ReaderPage data class PageThumbnail( val isCurrent: Boolean, - val repository: MangaRepository, val page: ReaderPage, ) : ListModel { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/PagesThumbnailsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/PagesThumbnailsViewModel.kt index 27e5d2015..16ad32e1a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/PagesThumbnailsViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/PagesThumbnailsViewModel.kt @@ -13,7 +13,6 @@ import kotlinx.coroutines.flow.stateIn import org.koitharu.kotatsu.core.model.findById import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.parser.MangaIntent -import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.util.ext.firstNotNull import org.koitharu.kotatsu.core.util.ext.require @@ -26,7 +25,6 @@ import javax.inject.Inject @HiltViewModel class PagesThumbnailsViewModel @Inject constructor( savedStateHandle: SavedStateHandle, - mangaRepositoryFactory: MangaRepository.Factory, private val chaptersLoader: ChaptersLoader, detailsLoadUseCase: DetailsLoadUseCase, ) : BaseViewModel() { @@ -36,14 +34,13 @@ class PagesThumbnailsViewModel @Inject constructor( private val initialChapterId: Long = savedStateHandle[PagesThumbnailsSheet.ARG_CHAPTER_ID] ?: 0L val manga = savedStateHandle.require(PagesThumbnailsSheet.ARG_MANGA).manga - private val repository = mangaRepositoryFactory.create(manga.source) private val mangaDetails = detailsLoadUseCase(MangaIntent.of(manga)).map { val b = manga.chapters?.findById(initialChapterId)?.branch branch.value = b it.filterChapters(b) }.withErrorHandling() .stateIn(viewModelScope, SharingStarted.Lazily, null) - private var loadingJob: Job? = null + private var loadingJob: Job private var loadingPrevJob: Job? = null private var loadingNextJob: Job? = null @@ -59,14 +56,14 @@ class PagesThumbnailsViewModel @Inject constructor( } fun loadPrevChapter() { - if (loadingJob?.isActive == true || loadingPrevJob?.isActive == true) { + if (loadingJob.isActive || loadingPrevJob?.isActive == true) { return } loadingPrevJob = loadPrevNextChapter(isNext = false) } fun loadNextChapter() { - if (loadingJob?.isActive == true || loadingNextJob?.isActive == true) { + if (loadingJob.isActive || loadingNextJob?.isActive == true) { return } loadingNextJob = loadPrevNextChapter(isNext = true) @@ -91,7 +88,6 @@ class PagesThumbnailsViewModel @Inject constructor( } this += PageThumbnail( isCurrent = page.chapterId == initialChapterId && page.index == currentPageIndex, - repository = repository, page = page, ) } diff --git a/app/src/main/res/layout-w600dp-land/activity_details.xml b/app/src/main/res/layout-w600dp-land/activity_details.xml index 03faa2f1f..d72752eac 100644 --- a/app/src/main/res/layout-w600dp-land/activity_details.xml +++ b/app/src/main/res/layout-w600dp-land/activity_details.xml @@ -113,12 +113,24 @@ app:layout_constraintStart_toEndOf="@id/container_details" app:layout_constraintTop_toBottomOf="@id/appbar"> - + android:orientation="vertical"> + + + + + + diff --git a/app/src/main/res/layout/activity_details.xml b/app/src/main/res/layout/activity_details.xml index bebcbb8f7..a84abac6e 100644 --- a/app/src/main/res/layout/activity_details.xml +++ b/app/src/main/res/layout/activity_details.xml @@ -98,12 +98,17 @@ - + android:layout_height="wrap_content" + android:background="@null" /> + + diff --git a/app/src/main/res/layout/fragment_pages.xml b/app/src/main/res/layout/fragment_pages.xml new file mode 100644 index 000000000..87c588d72 --- /dev/null +++ b/app/src/main/res/layout/fragment_pages.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + +