Batch pages saving

This commit is contained in:
Koitharu
2024-11-09 11:44:51 +02:00
parent bb6f7b1e9f
commit 635839065d
11 changed files with 183 additions and 28 deletions

View File

@@ -88,6 +88,10 @@ fun Manga.findChapter(id: Long): MangaChapter? {
return chapters?.findById(id)
}
fun Manga.requireChapter(id: Long): MangaChapter = checkNotNull(findChapter(id)) {
"Chapter $id not found"
}
fun Manga.getPreferredBranch(history: MangaHistory?): String? {
val ch = chapters
if (ch.isNullOrEmpty()) {

View File

@@ -2,8 +2,13 @@ package org.koitharu.kotatsu.details.ui.pager.pages
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.view.ActionMode
import androidx.collection.ArraySet
import androidx.core.graphics.Insets
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
@@ -20,10 +25,12 @@ 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.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.util.PagerNestedScrollHelper
import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
import org.koitharu.kotatsu.core.util.ext.dismissParentDialog
import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate
import org.koitharu.kotatsu.core.util.ext.findParentCallback
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
@@ -34,16 +41,18 @@ import org.koitharu.kotatsu.list.ui.GridSpanResolver
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.PageSaveHelper
import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
import org.koitharu.kotatsu.reader.ui.ReaderNavigationCallback
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import javax.inject.Inject
import kotlin.math.roundToInt
@AndroidEntryPoint
class PagesFragment :
BaseFragment<FragmentPagesBinding>(),
OnListItemClickListener<PageThumbnail> {
OnListItemClickListener<PageThumbnail>, ListSelectionController.Callback {
@Inject
lateinit var coil: ImageLoader
@@ -51,17 +60,23 @@ class PagesFragment :
@Inject
lateinit var settings: AppSettings
@Inject
lateinit var pageSaveHelperFactory: PageSaveHelper.Factory
private val parentViewModel by ChaptersPagesViewModel.ActivityVMLazy(this)
private val viewModel by viewModels<PagesViewModel>()
private lateinit var pageSaveHelper: PageSaveHelper
private var thumbnailsAdapter: PageThumbnailAdapter? = null
private var spanResolver: GridSpanResolver? = null
private var scrollListener: ScrollListener? = null
private var selectionController: ListSelectionController? = null
private val spanSizeLookup = SpanSizeLookup()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
pageSaveHelper = pageSaveHelperFactory.create(this)
combine(
parentViewModel.mangaDetails,
parentViewModel.readingState,
@@ -83,6 +98,12 @@ class PagesFragment :
override fun onViewBindingCreated(binding: FragmentPagesBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
spanResolver = GridSpanResolver(binding.root.resources)
selectionController = ListSelectionController(
appCompatDelegate = checkNotNull(findAppCompatDelegate()),
decoration = PagesSelectionDecoration(binding.root.context),
registryOwner = this,
callback = this,
)
thumbnailsAdapter = PageThumbnailAdapter(
coil = coil,
lifecycleOwner = viewLifecycleOwner,
@@ -91,6 +112,7 @@ class PagesFragment :
viewModel.gridScale.observe(viewLifecycleOwner, ::onGridScaleChanged) // before rv initialization
with(binding.recyclerView) {
addItemDecoration(TypedListSpacingDecoration(context, false))
checkNotNull(selectionController).attachToRecyclerView(this)
adapter = thumbnailsAdapter
setHasFixedSize(true)
PagerNestedScrollHelper(this).bind(viewLifecycleOwner)
@@ -103,6 +125,7 @@ class PagesFragment :
}
parentViewModel.isChaptersEmpty.observe(viewLifecycleOwner, ::onNoChaptersChanged)
viewModel.thumbnails.observe(viewLifecycleOwner, ::onThumbnailsChanged)
viewModel.onPageSaved.observeEvent(this, PagesSavedObserver(binding.recyclerView))
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
viewModel.isLoading.observe(viewLifecycleOwner) { binding.progressBar.showOrHide(it) }
viewModel.isLoadingUp.observe(viewLifecycleOwner) { binding.progressBarTop.showOrHide(it) }
@@ -113,6 +136,7 @@ class PagesFragment :
spanResolver = null
scrollListener = null
thumbnailsAdapter = null
selectionController = null
spanSizeLookup.invalidateCache()
super.onDestroyView()
}
@@ -120,6 +144,9 @@ class PagesFragment :
override fun onWindowInsetsChanged(insets: Insets) = Unit
override fun onItemClick(item: PageThumbnail, view: View) {
if (selectionController?.onItemClick(item.page.id) == true) {
return
}
val listener = findParentCallback(ReaderNavigationCallback::class.java)
if (listener != null && listener.onPageSelected(item.page)) {
dismissParentDialog()
@@ -133,6 +160,39 @@ class PagesFragment :
}
}
override fun onItemLongClick(item: PageThumbnail, view: View): Boolean {
return selectionController?.onItemLongClick(view, item.page.id) ?: false
}
override fun onItemContextClick(item: PageThumbnail, view: View): Boolean {
return selectionController?.onItemContextClick(view, item.page.id) ?: false
}
override fun onSelectionChanged(controller: ListSelectionController, count: Int) {
viewBinding?.recyclerView?.invalidateItemDecorations()
}
override fun onCreateActionMode(
controller: ListSelectionController,
menuInflater: MenuInflater,
menu: Menu,
): Boolean {
menuInflater.inflate(R.menu.mode_pages, menu)
return true
}
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode?, item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_save -> {
viewModel.savePages(pageSaveHelper, collectSelectedPages())
mode?.finish()
true
}
else -> false
}
}
private suspend fun onThumbnailsChanged(list: List<ListModel>) {
val adapter = thumbnailsAdapter ?: return
if (adapter.itemCount == 0) {
@@ -172,6 +232,18 @@ class PagesFragment :
}
}
private fun collectSelectedPages(): Set<ReaderPage> {
val checkedIds = selectionController?.peekCheckedIds() ?: return emptySet()
val items = thumbnailsAdapter?.items ?: return emptySet()
val result = ArraySet<ReaderPage>(checkedIds.size)
for (item in items) {
if (item is PageThumbnail && item.page.id in checkedIds) {
result.add(item.page)
}
}
return result
}
private inner class ScrollListener : BoundsScrollListener(3, 3) {
override fun onScrolledToStart(recyclerView: RecyclerView) {

View File

@@ -0,0 +1,28 @@
package org.koitharu.kotatsu.details.ui.pager.pages
import android.net.Uri
import android.view.View
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.flow.FlowCollector
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ShareHelper
class PagesSavedObserver(
private val snackbarHost: View,
) : FlowCollector<Collection<Uri>> {
override suspend fun emit(value: Collection<Uri>) {
val msg = when (value.size) {
0 -> R.string.nothing_found
1 -> R.string.page_saved
else -> R.string.pages_saved
}
val snackbar = Snackbar.make(snackbarHost, msg, Snackbar.LENGTH_LONG)
value.singleOrNull()?.let { uri ->
snackbar.setAction(R.string.share) {
ShareHelper(snackbarHost.context).shareImage(uri)
}
}
snackbar.show()
}
}

View File

@@ -0,0 +1,16 @@
package org.koitharu.kotatsu.details.ui.pager.pages
import android.content.Context
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.core.util.ext.getItem
import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration
class PagesSelectionDecoration(context: Context) : MangaSelectionDecoration(context) {
override fun getItemId(parent: RecyclerView, child: View): Long {
val holder = parent.getChildViewHolder(child) ?: return RecyclerView.NO_ID
val item = holder.getItem(PageThumbnail::class.java) ?: return RecyclerView.NO_ID
return item.page.id
}
}

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.details.ui.pager.pages
import android.net.Uri
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
@@ -7,15 +8,21 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.model.requireChapter
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.firstNotNull
import org.koitharu.kotatsu.core.util.ext.requireValue
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.PageSaveHelper
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import javax.inject.Inject
@HiltViewModel
@@ -32,6 +39,7 @@ class PagesViewModel @Inject constructor(
val thumbnails = MutableStateFlow<List<ListModel>>(emptyList())
val isLoadingUp = MutableStateFlow(false)
val isLoadingDown = MutableStateFlow(false)
val onPageSaved = MutableEventFlow<Collection<Uri>>()
val gridScale = settings.observeAsStateFlow(
scope = viewModelScope + Dispatchers.Default,
@@ -73,6 +81,25 @@ class PagesViewModel @Inject constructor(
loadingNextJob = loadPrevNextChapter(isNext = true)
}
fun savePages(
pageSaveHelper: PageSaveHelper,
pages: Set<ReaderPage>,
) {
launchLoadingJob(Dispatchers.Default) {
val manga = state.requireValue().details.toManga()
val tasks = pages.map {
PageSaveHelper.Task(
manga = manga,
chapter = manga.requireChapter(it.chapterId),
pageNumber = it.index + 1,
page = it.toMangaPage(),
)
}
val dest = pageSaveHelper.save(tasks)
onPageSaved.call(dest)
}
}
private suspend fun doInit(state: State) {
chaptersLoader.init(state.details)
val initialChapterId = state.readerState?.chapterId?.takeIf {

View File

@@ -115,9 +115,9 @@ abstract class MangaListFragment :
with(binding.recyclerView) {
setHasFixedSize(true)
adapter = listAdapter
checkNotNull(selectionController).attachToRecyclerView(binding.recyclerView)
checkNotNull(selectionController).attachToRecyclerView(this)
addItemDecoration(TypedListSpacingDecoration(context, false))
addOnScrollListener(paginationListener!!)
addOnScrollListener(checkNotNull(paginationListener))
fastScroller.setFastScrollListener(this@MangaListFragment)
}
with(binding.swipeRefreshLayout) {

View File

@@ -67,17 +67,14 @@ class PageSaveHelper @AssistedInject constructor(
}
}
suspend fun save(tasks: Set<Task>): Uri? = when (tasks.size) {
0 -> null
1 -> saveImpl(tasks.first())
else -> {
saveImpl(tasks)
null
}
suspend fun save(tasks: Collection<Task>): Collection<Uri> = when (tasks.size) {
0 -> emptySet()
1 -> setOf(saveImpl(tasks.first()))
else -> saveImpl(tasks)
}
private suspend fun saveImpl(task: Task): Uri {
val pageLoader = pageLoaderProvider.get()
val pageLoader = getPageLoader()
val pageUrl = pageLoader.getPageUrl(task.page).toUri()
val pageUri = pageLoader.loadPage(task.page, force = false)
val proposedName = task.getFileBaseName() + "." + getPageExtension(pageUrl, pageUri)
@@ -89,13 +86,14 @@ class PageSaveHelper @AssistedInject constructor(
return destination
}
private suspend fun saveImpl(tasks: Collection<Task>) {
val pageLoader = pageLoaderProvider.get()
private suspend fun saveImpl(tasks: Collection<Task>): Collection<Uri> {
val pageLoader = getPageLoader()
val destinationDir = getDefaultFileUri(null) ?: run {
val defaultUri = settings.getPagesSaveDir(context)?.uri
DocumentFile.fromTreeUri(context, pickDirectoryRequest.launchAndAwait(defaultUri))
} ?: throw IOException("Cannot get destination directory")
val result = ArrayList<Uri>(tasks.size)
for (task in tasks) {
val pageUrl = pageLoader.getPageUrl(task.page).toUri()
val pageUri = pageLoader.loadPage(task.page, force = false)
@@ -106,7 +104,9 @@ class PageSaveHelper @AssistedInject constructor(
}
val destination = destinationDir.createFile(mime, proposedName.substringBeforeLast('.'))
copyImpl(pageUri, destination?.uri ?: throw IOException("Cannot create destination file"))
result.add(destination.uri)
}
return result
}
private suspend fun getPageExtension(url: Uri, fileUri: Uri): String {
@@ -143,6 +143,10 @@ class PageSaveHelper @AssistedInject constructor(
}
}
private suspend fun getPageLoader() = withContext(Dispatchers.Main.immediate) {
pageLoaderProvider.get()
}
private fun getDefaultFileUri(proposedName: String?): DocumentFile? {
if (settings.isPagesSavingAskEnabled) {
return null

View File

@@ -53,6 +53,7 @@ import org.koitharu.kotatsu.core.util.ext.setValueRounded
import org.koitharu.kotatsu.core.util.ext.zipWithPrevious
import org.koitharu.kotatsu.databinding.ActivityReaderBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.details.ui.pager.pages.PagesSavedObserver
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.reader.data.TapGridSettings
@@ -143,7 +144,7 @@ class ReaderActivity :
),
)
viewModel.readerMode.observe(this, Lifecycle.State.STARTED, this::onInitReader)
viewModel.onPageSaved.observeEvent(this, this::onPageSaved)
viewModel.onPageSaved.observeEvent(this, PagesSavedObserver(viewBinding.container))
viewModel.uiState.zipWithPrevious().observe(this, this::onUiStateChanged)
viewModel.isLoading.observe(this, this::onLoadingStateChanged)
viewModel.content.observe(this) {
@@ -289,17 +290,6 @@ class ReaderActivity :
readerManager.setDoubleReaderMode(isEnabled)
}
private fun onPageSaved(uri: Uri?) {
val snackbar = Snackbar.make(viewBinding.container, R.string.page_saved, Snackbar.LENGTH_LONG)
if (uri != null) {
snackbar.setAction(R.string.share) {
ShareHelper(this).shareImage(uri)
}
}
snackbar.setAnchorView(viewBinding.appbarBottom)
snackbar.show()
}
private fun setKeepScreenOn(isKeep: Boolean) {
if (isKeep) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)

View File

@@ -32,6 +32,7 @@ import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
import org.koitharu.kotatsu.core.model.findChapter
import org.koitharu.kotatsu.core.model.getPreferredBranch
import org.koitharu.kotatsu.core.model.requireChapter
import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaIntent
@@ -111,7 +112,7 @@ class ReaderViewModel @Inject constructor(
}
val readerMode = MutableStateFlow<ReaderMode?>(null)
val onPageSaved = MutableEventFlow<Uri?>()
val onPageSaved = MutableEventFlow<Collection<Uri>>()
val onShowToast = MutableEventFlow<Int>()
val uiState = MutableStateFlow<ReaderUiState?>(null)
@@ -261,8 +262,8 @@ class ReaderViewModel @Inject constructor(
val currentManga = manga.requireValue()
val task = PageSaveHelper.Task(
manga = currentManga,
chapter = checkNotNull(currentManga.findChapter(state.chapterId)),
pageNumber = state.page,
chapter = currentManga.requireChapter(state.chapterId),
pageNumber = state.page + 1,
page = checkNotNull(getCurrentPage()) { "Cannot find current page" },
)
val dest = pageSaveHelper.save(setOf(task))

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_save"
android:icon="@drawable/ic_save"
android:title="@string/save"
app:showAsAction="ifRoom|withText" />
</menu>

View File

@@ -57,6 +57,7 @@
<string name="_s_deleted_from_local_storage">\"%s\" deleted from local storage</string>
<string name="save_page">Save page</string>
<string name="page_saved">Page saved</string>
<string name="pages_saved">Pages saved</string>
<string name="share_image">Share image</string>
<string name="_import">Import</string>
<string name="delete">Delete</string>