Slider in reader #204
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
package org.koitharu.kotatsu.reader.domain
|
||||
|
||||
import androidx.collection.LongSparseArray
|
||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
||||
|
||||
class ChapterPages private constructor(private val pages: ArrayDeque<ReaderPage>) : List<ReaderPage> by pages {
|
||||
|
||||
// map chapterId to index in pages deque
|
||||
private val indices = LongSparseArray<IntRange>()
|
||||
|
||||
constructor() : this(ArrayDeque())
|
||||
|
||||
val chaptersSize: Int
|
||||
get() = indices.size()
|
||||
|
||||
fun removeFirst() {
|
||||
val chapterId = pages.first().chapterId
|
||||
indices.remove(chapterId)
|
||||
var delta = 0
|
||||
while (pages.first().chapterId == chapterId) {
|
||||
pages.removeFirst()
|
||||
delta--
|
||||
}
|
||||
shiftIndices(delta)
|
||||
}
|
||||
|
||||
fun removeLast() {
|
||||
val chapterId = pages.last().chapterId
|
||||
indices.remove(chapterId)
|
||||
while (pages.last().chapterId == chapterId) {
|
||||
pages.removeLast()
|
||||
}
|
||||
}
|
||||
|
||||
fun addLast(id: Long, newPages: List<ReaderPage>) {
|
||||
indices.put(id, pages.size until (pages.size + newPages.size))
|
||||
pages.addAll(newPages)
|
||||
}
|
||||
|
||||
fun addFirst(id: Long, newPages: List<ReaderPage>) {
|
||||
shiftIndices(newPages.size)
|
||||
indices.put(id, newPages.indices)
|
||||
pages.addAll(0, newPages)
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
indices.clear()
|
||||
pages.clear()
|
||||
}
|
||||
|
||||
fun size(id: Long) = indices[id]?.run {
|
||||
endInclusive - start + 1
|
||||
} ?: 0
|
||||
|
||||
fun subList(id: Long): List<ReaderPage> {
|
||||
val range = indices[id] ?: return emptyList()
|
||||
return pages.subList(range.first, range.last + 1)
|
||||
}
|
||||
|
||||
private fun shiftIndices(delta: Int) {
|
||||
for (i in 0 until indices.size()) {
|
||||
val range = indices.valueAt(i)
|
||||
indices.setValueAt(i, range + delta)
|
||||
}
|
||||
}
|
||||
|
||||
private operator fun IntRange.plus(delta: Int): IntRange {
|
||||
return IntRange(start + delta, endInclusive + delta)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package org.koitharu.kotatsu.reader.domain
|
||||
|
||||
import android.util.LongSparseArray
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
||||
|
||||
private const val PAGES_TRIM_THRESHOLD = 120
|
||||
|
||||
class ChaptersLoader {
|
||||
|
||||
val chapters = LongSparseArray<MangaChapter>()
|
||||
private val chapterPages = ChapterPages()
|
||||
private val mutex = Mutex()
|
||||
|
||||
suspend fun loadPrevNextChapter(manga: Manga, currentId: Long, isNext: Boolean) {
|
||||
val chapters = manga.chapters ?: return
|
||||
val predicate: (MangaChapter) -> Boolean = { it.id == currentId }
|
||||
val index = if (isNext) chapters.indexOfFirst(predicate) else chapters.indexOfLast(predicate)
|
||||
if (index == -1) return
|
||||
val newChapter = chapters.getOrNull(if (isNext) index + 1 else index - 1) ?: return
|
||||
val newPages = loadChapter(manga, newChapter.id)
|
||||
mutex.withLock {
|
||||
if (chapterPages.chaptersSize > 1) {
|
||||
// trim pages
|
||||
if (chapterPages.size > PAGES_TRIM_THRESHOLD) {
|
||||
if (isNext) {
|
||||
chapterPages.removeFirst()
|
||||
} else {
|
||||
chapterPages.removeLast()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isNext) {
|
||||
chapterPages.addLast(newChapter.id, newPages)
|
||||
} else {
|
||||
chapterPages.addFirst(newChapter.id, newPages)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadSingleChapter(manga: Manga, chapterId: Long) {
|
||||
val pages = loadChapter(manga, chapterId)
|
||||
mutex.withLock {
|
||||
chapterPages.clear()
|
||||
chapterPages.addLast(chapterId, pages)
|
||||
}
|
||||
}
|
||||
|
||||
fun getPages(chapterId: Long): List<ReaderPage> {
|
||||
return chapterPages.subList(chapterId)
|
||||
}
|
||||
|
||||
fun getPagesCount(chapterId: Long): Int {
|
||||
return chapterPages.size(chapterId)
|
||||
}
|
||||
|
||||
fun snapshot() = chapterPages.toList()
|
||||
|
||||
private suspend fun loadChapter(manga: Manga, chapterId: Long): List<ReaderPage> {
|
||||
val chapter = checkNotNull(chapters[chapterId]) { "Requested chapter not found" }
|
||||
val repo = MangaRepository(manga.source)
|
||||
return repo.getPages(chapter).mapIndexed { index, page ->
|
||||
ReaderPage(page, index, chapterId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.koitharu.kotatsu.reader.ui
|
||||
|
||||
import com.google.android.material.slider.LabelFormatter
|
||||
import org.koitharu.kotatsu.parsers.util.format
|
||||
|
||||
class PageLabelFormatter : LabelFormatter {
|
||||
|
||||
override fun getFormattedValue(value: Float): String {
|
||||
return (value + 1).format(0)
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import androidx.core.view.OnApplyWindowInsetsListener
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.lifecycle.flowWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.transition.Slide
|
||||
import androidx.transition.TransitionManager
|
||||
@@ -20,8 +19,6 @@ import androidx.transition.TransitionSet
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.android.ext.android.get
|
||||
@@ -44,7 +41,6 @@ import org.koitharu.kotatsu.reader.ui.thumbnails.OnPageSelectListener
|
||||
import org.koitharu.kotatsu.reader.ui.thumbnails.PagesThumbnailsSheet
|
||||
import org.koitharu.kotatsu.settings.SettingsActivity
|
||||
import org.koitharu.kotatsu.utils.GridTouchHelper
|
||||
import org.koitharu.kotatsu.utils.ScreenOrientationHelper
|
||||
import org.koitharu.kotatsu.utils.ShareHelper
|
||||
import org.koitharu.kotatsu.utils.ext.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
@@ -68,7 +64,6 @@ class ReaderActivity :
|
||||
}
|
||||
|
||||
private lateinit var touchHelper: GridTouchHelper
|
||||
private lateinit var orientationHelper: ScreenOrientationHelper
|
||||
private lateinit var controlDelegate: ReaderControlDelegate
|
||||
private val savePageRequest = registerForActivityResult(PageSaveContract(), this)
|
||||
private var gestureInsets: Insets = Insets.NONE
|
||||
@@ -81,18 +76,12 @@ class ReaderActivity :
|
||||
readerManager = ReaderManager(supportFragmentManager, R.id.container)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
touchHelper = GridTouchHelper(this, this)
|
||||
orientationHelper = ScreenOrientationHelper(this)
|
||||
controlDelegate = ReaderControlDelegate(lifecycleScope, get(), this)
|
||||
binding.toolbarBottom.inflateMenu(R.menu.opt_reader_bottom)
|
||||
binding.toolbarBottom.setOnMenuItemClickListener(::onOptionsItemSelected)
|
||||
binding.slider.setLabelFormatter(PageLabelFormatter())
|
||||
ReaderSliderListener(this, viewModel).attachToSlider(binding.slider)
|
||||
insetsDelegate.interceptingWindowInsetsListener = this
|
||||
|
||||
orientationHelper.observeAutoOrientation()
|
||||
.flowWithLifecycle(lifecycle)
|
||||
.onEach {
|
||||
binding.toolbarBottom.menu.findItem(R.id.action_screen_rotate).isVisible = !it
|
||||
}.launchIn(lifecycleScope)
|
||||
|
||||
viewModel.onError.observe(this, this::onError)
|
||||
viewModel.readerMode.observe(this, this::onInitReader)
|
||||
viewModel.onPageSaved.observe(this, this::onPageSaved)
|
||||
@@ -114,15 +103,6 @@ class ReaderActivity :
|
||||
if (readerManager.currentMode != mode) {
|
||||
readerManager.replace(mode)
|
||||
}
|
||||
val iconRes = when (mode) {
|
||||
ReaderMode.WEBTOON -> R.drawable.ic_script
|
||||
ReaderMode.REVERSED -> R.drawable.ic_read_reversed
|
||||
ReaderMode.STANDARD -> R.drawable.ic_book_page
|
||||
}
|
||||
binding.toolbarBottom.menu.findItem(R.id.action_reader_mode).run {
|
||||
setIcon(iconRes)
|
||||
setVisible(true)
|
||||
}
|
||||
if (binding.appbarTop.isVisible) {
|
||||
lifecycle.postDelayed(hideUiRunnable, TimeUnit.SECONDS.toMillis(1))
|
||||
}
|
||||
@@ -136,7 +116,7 @@ class ReaderActivity :
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
R.id.action_reader_mode -> {
|
||||
R.id.action_menu -> {
|
||||
val currentMode = readerManager.currentMode ?: return false
|
||||
ReaderConfigDialog.show(supportFragmentManager, currentMode)
|
||||
}
|
||||
@@ -150,9 +130,6 @@ class ReaderActivity :
|
||||
viewModel.getCurrentState()?.chapterId ?: 0L
|
||||
)
|
||||
}
|
||||
R.id.action_screen_rotate -> {
|
||||
orientationHelper.toggleOrientation()
|
||||
}
|
||||
R.id.action_pages_thumbs -> {
|
||||
val pages = viewModel.getCurrentChapterPages()
|
||||
if (!pages.isNullOrEmpty()) {
|
||||
@@ -166,12 +143,6 @@ class ReaderActivity :
|
||||
return false
|
||||
}
|
||||
}
|
||||
R.id.action_save_page -> {
|
||||
viewModel.getCurrentPage()?.also { page ->
|
||||
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
|
||||
viewModel.saveCurrentPage(page, savePageRequest)
|
||||
} ?: return false
|
||||
}
|
||||
R.id.action_bookmark -> {
|
||||
if (viewModel.isBookmarkAdded.value == true) {
|
||||
viewModel.removeBookmark()
|
||||
@@ -199,7 +170,6 @@ class ReaderActivity :
|
||||
val menu = binding.toolbarBottom.menu
|
||||
menu.findItem(R.id.action_bookmark).isVisible = hasPages
|
||||
menu.findItem(R.id.action_pages_thumbs).isVisible = hasPages
|
||||
menu.findItem(R.id.action_save_page).isVisible = hasPages
|
||||
}
|
||||
|
||||
private fun onError(e: Throwable) {
|
||||
@@ -351,6 +321,7 @@ class ReaderActivity :
|
||||
title = uiState?.chapterName ?: uiState?.mangaName ?: getString(R.string.loading_)
|
||||
if (uiState == null) {
|
||||
supportActionBar?.subtitle = null
|
||||
binding.slider.isVisible = false
|
||||
return
|
||||
}
|
||||
supportActionBar?.subtitle = if (uiState.chapterNumber in 1..uiState.chaptersTotal) {
|
||||
@@ -363,6 +334,13 @@ class ReaderActivity :
|
||||
binding.toastView.showTemporary(uiState.chapterName, TOAST_DURATION)
|
||||
}
|
||||
}
|
||||
if (uiState.totalPages > 0) {
|
||||
binding.slider.value = uiState.currentPage.toFloat()
|
||||
binding.slider.valueTo = uiState.totalPages.toFloat()
|
||||
binding.slider.isVisible = true
|
||||
} else {
|
||||
binding.slider.isVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
private inner class ErrorDialogListener(
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package org.koitharu.kotatsu.reader.ui
|
||||
|
||||
import com.google.android.material.slider.Slider
|
||||
import org.koitharu.kotatsu.reader.ui.thumbnails.OnPageSelectListener
|
||||
|
||||
class ReaderSliderListener(
|
||||
private val pageSelectListener: OnPageSelectListener,
|
||||
private val viewModel: ReaderViewModel,
|
||||
) : Slider.OnChangeListener, Slider.OnSliderTouchListener {
|
||||
|
||||
private var isChanged = false
|
||||
|
||||
override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) {
|
||||
if (fromUser) {
|
||||
isChanged = true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartTrackingTouch(slider: Slider) {
|
||||
isChanged = false
|
||||
}
|
||||
|
||||
override fun onStopTrackingTouch(slider: Slider) {
|
||||
if (isChanged) {
|
||||
switchPageToIndex(slider.value.toInt())
|
||||
}
|
||||
}
|
||||
|
||||
fun attachToSlider(slider: Slider) {
|
||||
slider.addOnChangeListener(this)
|
||||
slider.addOnSliderTouchListener(this)
|
||||
}
|
||||
|
||||
private fun switchPageToIndex(index: Int) {
|
||||
val pages = viewModel.getCurrentChapterPages()
|
||||
val page = pages?.getOrNull(index) ?: return
|
||||
pageSelectListener.onPageSelected(page)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.reader.ui
|
||||
import android.net.Uri
|
||||
import android.util.LongSparseArray
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.annotation.AnyThread
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
@@ -24,17 +25,17 @@ 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.data.filterChapters
|
||||
import org.koitharu.kotatsu.reader.domain.ChaptersLoader
|
||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
|
||||
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
||||
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
||||
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.utils.ext.processLifecycleScope
|
||||
import org.koitharu.kotatsu.utils.ext.requireValue
|
||||
import java.util.*
|
||||
|
||||
private const val BOUNDS_PAGE_OFFSET = 2
|
||||
private const val PAGES_TRIM_THRESHOLD = 120
|
||||
private const val PREFETCH_LIMIT = 10
|
||||
|
||||
class ReaderViewModel(
|
||||
@@ -53,25 +54,16 @@ class ReaderViewModel(
|
||||
private var bookmarkJob: Job? = null
|
||||
private val currentState = MutableStateFlow(initialState)
|
||||
private val mangaData = MutableStateFlow(intent.manga)
|
||||
private val chapters = LongSparseArray<MangaChapter>()
|
||||
private val chapters: LongSparseArray<MangaChapter>
|
||||
get() = chaptersLoader.chapters
|
||||
|
||||
val pageLoader = PageLoader()
|
||||
private val chaptersLoader = ChaptersLoader()
|
||||
|
||||
val readerMode = MutableLiveData<ReaderMode>()
|
||||
val onPageSaved = SingleLiveEvent<Uri?>()
|
||||
val onShowToast = SingleLiveEvent<Int>()
|
||||
val uiState: LiveData<ReaderUiState?> = combine(
|
||||
mangaData,
|
||||
currentState,
|
||||
) { manga, state ->
|
||||
val chapter = state?.chapterId?.let(chapters::get)
|
||||
ReaderUiState(
|
||||
mangaName = manga?.title,
|
||||
chapterName = chapter?.name,
|
||||
chapterNumber = chapter?.number ?: 0,
|
||||
chaptersTotal = chapters.size()
|
||||
)
|
||||
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, null)
|
||||
val uiState = MutableLiveData<ReaderUiState?>(null)
|
||||
|
||||
val content = MutableLiveData(ReaderContent(emptyList(), null))
|
||||
val manga: Manga?
|
||||
@@ -134,7 +126,6 @@ class ReaderViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
// TODO check performance
|
||||
fun saveCurrentState(state: ReaderState? = null) {
|
||||
if (state != null) {
|
||||
currentState.value = state
|
||||
@@ -151,8 +142,7 @@ class ReaderViewModel(
|
||||
|
||||
fun getCurrentChapterPages(): List<MangaPage>? {
|
||||
val chapterId = currentState.value?.chapterId ?: return null
|
||||
val pages = content.value?.pages ?: return null
|
||||
return pages.filter { it.chapterId == chapterId }.map { it.toMangaPage() }
|
||||
return chaptersLoader.getPages(chapterId).map { it.toMangaPage() }
|
||||
}
|
||||
|
||||
fun saveCurrentPage(
|
||||
@@ -195,11 +185,12 @@ class ReaderViewModel(
|
||||
loadingJob = launchLoadingJob(Dispatchers.Default) {
|
||||
prevJob?.cancelAndJoin()
|
||||
content.postValue(ReaderContent(emptyList(), null))
|
||||
val newPages = loadChapter(id)
|
||||
content.postValue(ReaderContent(newPages, ReaderState(id, 0, 0)))
|
||||
chaptersLoader.loadSingleChapter(mangaData.requireValue(), id)
|
||||
content.postValue(ReaderContent(chaptersLoader.snapshot(), ReaderState(id, 0, 0)))
|
||||
}
|
||||
}
|
||||
|
||||
// TODO move to background?
|
||||
fun onCurrentPageChanged(position: Int) {
|
||||
val pages = content.value?.pages ?: return
|
||||
pages.getOrNull(position)?.let { page ->
|
||||
@@ -207,14 +198,15 @@ class ReaderViewModel(
|
||||
cs?.copy(chapterId = page.chapterId, page = page.index)
|
||||
}
|
||||
}
|
||||
notifyStateChanged()
|
||||
if (pages.isEmpty() || loadingJob?.isActive == true) {
|
||||
return
|
||||
}
|
||||
if (position <= BOUNDS_PAGE_OFFSET) {
|
||||
loadPrevNextChapter(pages.first().chapterId, -1)
|
||||
loadPrevNextChapter(pages.first().chapterId, isNext = false)
|
||||
}
|
||||
if (position >= pages.size - BOUNDS_PAGE_OFFSET) {
|
||||
loadPrevNextChapter(pages.last().chapterId, 1)
|
||||
loadPrevNextChapter(pages.last().chapterId, isNext = true)
|
||||
}
|
||||
if (pageLoader.isPrefetchApplicable()) {
|
||||
pageLoader.prefetch(pages.trySublist(position + 1, position + PREFETCH_LIMIT))
|
||||
@@ -279,55 +271,21 @@ class ReaderViewModel(
|
||||
mangaData.value = manga.filterChapters(branch)
|
||||
readerMode.postValue(mode)
|
||||
|
||||
val pages = loadChapter(requireNotNull(currentState.value).chapterId)
|
||||
chaptersLoader.loadSingleChapter(manga, requireNotNull(currentState.value).chapterId)
|
||||
// save state
|
||||
currentState.value?.let {
|
||||
val percent = computePercent(it.chapterId, it.page)
|
||||
historyRepository.addOrUpdate(manga, it.chapterId, it.page, it.scroll, percent)
|
||||
}
|
||||
|
||||
content.postValue(ReaderContent(pages, currentState.value))
|
||||
notifyStateChanged()
|
||||
content.postValue(ReaderContent(chaptersLoader.snapshot(), currentState.value))
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadChapter(chapterId: Long): List<ReaderPage> {
|
||||
val manga = checkNotNull(mangaData.value) { "Manga is null" }
|
||||
val chapter = checkNotNull(chapters[chapterId]) { "Requested chapter not found" }
|
||||
val repo = MangaRepository(manga.source)
|
||||
return repo.getPages(chapter).mapIndexed { index, page ->
|
||||
ReaderPage(page, index, chapterId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadPrevNextChapter(currentId: Long, delta: Int) {
|
||||
private fun loadPrevNextChapter(currentId: Long, isNext: Boolean) {
|
||||
loadingJob = launchLoadingJob(Dispatchers.Default) {
|
||||
val chapters = mangaData.value?.chapters ?: return@launchLoadingJob
|
||||
val predicate: (MangaChapter) -> Boolean = { it.id == currentId }
|
||||
val index =
|
||||
if (delta < 0) chapters.indexOfLast(predicate) else chapters.indexOfFirst(predicate)
|
||||
if (index == -1) return@launchLoadingJob
|
||||
val newChapter = chapters.getOrNull(index + delta) ?: return@launchLoadingJob
|
||||
val newPages = loadChapter(newChapter.id)
|
||||
var currentPages = content.value?.pages ?: return@launchLoadingJob
|
||||
// trim pages
|
||||
if (currentPages.size > PAGES_TRIM_THRESHOLD) {
|
||||
val firstChapterId = currentPages.first().chapterId
|
||||
val lastChapterId = currentPages.last().chapterId
|
||||
if (firstChapterId != lastChapterId) {
|
||||
currentPages = when (delta) {
|
||||
1 -> currentPages.dropWhile { it.chapterId == firstChapterId }
|
||||
-1 -> currentPages.dropLastWhile { it.chapterId == lastChapterId }
|
||||
else -> currentPages
|
||||
}
|
||||
}
|
||||
}
|
||||
val pages = when (delta) {
|
||||
0 -> newPages
|
||||
-1 -> newPages + currentPages
|
||||
1 -> currentPages + newPages
|
||||
else -> error("Invalid delta $delta")
|
||||
}
|
||||
content.postValue(ReaderContent(pages, null))
|
||||
chaptersLoader.loadPrevNextChapter(mangaData.requireValue(), currentId, isNext)
|
||||
content.postValue(ReaderContent(chaptersLoader.snapshot(), null))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,12 +326,26 @@ class ReaderViewModel(
|
||||
}.getOrDefault(defaultMode)
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
private fun notifyStateChanged() {
|
||||
val state = getCurrentState()
|
||||
val chapter = state?.chapterId?.let(chapters::get)
|
||||
val newState = ReaderUiState(
|
||||
mangaName = manga?.title,
|
||||
chapterName = chapter?.name,
|
||||
chapterNumber = chapter?.number ?: 0,
|
||||
chaptersTotal = chapters.size(),
|
||||
totalPages = if (chapter != null) chaptersLoader.getPagesCount(chapter.id) else 0,
|
||||
currentPage = state?.page ?: 0,
|
||||
)
|
||||
uiState.postValue(newState)
|
||||
}
|
||||
|
||||
private fun computePercent(chapterId: Long, pageIndex: Int): Float {
|
||||
val chapters = manga?.chapters ?: return PROGRESS_NONE
|
||||
val chaptersCount = chapters.size
|
||||
val chapterIndex = chapters.indexOfFirst { x -> x.id == chapterId }
|
||||
val pages = content.value?.pages ?: return PROGRESS_NONE
|
||||
val pagesCount = pages.count { x -> x.chapterId == chapterId }
|
||||
val pagesCount = chaptersLoader.getPagesCount(chapterId)
|
||||
if (chaptersCount == 0 || pagesCount == 0) {
|
||||
return PROGRESS_NONE
|
||||
}
|
||||
@@ -401,4 +373,4 @@ private fun HistoryRepository.saveStateAsync(manga: Manga, state: ReaderState, p
|
||||
it.printStackTraceDebug()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,5 +4,7 @@ data class ReaderUiState(
|
||||
val mangaName: String?,
|
||||
val chapterName: String?,
|
||||
val chapterNumber: Int,
|
||||
val chaptersTotal: Int
|
||||
)
|
||||
val chaptersTotal: Int,
|
||||
val currentPage: Int,
|
||||
val totalPages: Int,
|
||||
)
|
||||
|
||||
@@ -2,10 +2,7 @@ package org.koitharu.kotatsu.utils.ext
|
||||
|
||||
import android.os.SystemClock
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
import kotlinx.coroutines.flow.*
|
||||
|
||||
fun <T> Flow<T>.onFirst(action: suspend (T) -> Unit): Flow<T> {
|
||||
var isFirstCall = true
|
||||
@@ -34,4 +31,8 @@ fun <T> Flow<T>.throttle(timeoutMillis: (T) -> Long): Flow<T> {
|
||||
emit(value)
|
||||
lastEmittedAt = now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> StateFlow<T?>.requireValue(): T = checkNotNull(value) {
|
||||
"StateFlow value is null"
|
||||
}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:keepScreenOn="true">
|
||||
|
||||
<androidx.fragment.app.FragmentContainerView
|
||||
android:id="@+id/container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<org.koitharu.kotatsu.reader.ui.ReaderToastView
|
||||
android:id="@+id/toastView"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom|center_horizontal"
|
||||
android:layout_marginBottom="20dp"
|
||||
android:background="@drawable/bg_reader_indicator"
|
||||
android:drawablePadding="6dp"
|
||||
android:singleLine="true"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:theme="@style/ThemeOverlay.Material3.Dark"
|
||||
tools:text="@string/loading_" />
|
||||
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:id="@+id/appbar_top"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/dim"
|
||||
android:elevation="0dp"
|
||||
android:theme="@style/ThemeOverlay.Material3.Dark"
|
||||
app:elevation="0dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@id/toolbar"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
app:popupTheme="@style/ThemeOverlay.Kotatsu" />
|
||||
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@+id/toolbar_bottom"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:popupTheme="@style/ThemeOverlay.Kotatsu" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_loading"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical">
|
||||
|
||||
<com.google.android.material.progressindicator.CircularProgressIndicator
|
||||
android:id="@+id/progressBar"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:indeterminate="true" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView_loading"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/loading_"
|
||||
android:textAppearance="?attr/textAppearanceBody2" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</FrameLayout>
|
||||
@@ -58,7 +58,19 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="top"
|
||||
app:popupTheme="@style/ThemeOverlay.Kotatsu" />
|
||||
app:menu="@menu/opt_reader_bottom"
|
||||
app:popupTheme="@style/ThemeOverlay.Kotatsu">
|
||||
|
||||
<com.google.android.material.slider.Slider
|
||||
android:id="@+id/slider"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:stepSize="1"
|
||||
android:valueFrom="0"
|
||||
app:labelBehavior="floating"
|
||||
app:tickVisible="false" />
|
||||
|
||||
</com.google.android.material.appbar.MaterialToolbar>
|
||||
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
@@ -86,4 +98,4 @@
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</FrameLayout>
|
||||
</FrameLayout>
|
||||
|
||||
@@ -20,28 +20,9 @@
|
||||
app:showAsAction="always" />
|
||||
|
||||
<item
|
||||
android:id="@+id/action_screen_rotate"
|
||||
android:icon="@drawable/ic_screen_rotation"
|
||||
android:title="@string/rotate_screen"
|
||||
android:visible="false"
|
||||
android:id="@+id/action_menu"
|
||||
android:icon="@drawable/abc_ic_menu_overflow_material"
|
||||
android:title="@string/options"
|
||||
app:showAsAction="always" />
|
||||
|
||||
<item
|
||||
android:id="@+id/action_reader_mode"
|
||||
android:icon="@drawable/ic_loading"
|
||||
android:title="@string/read_mode"
|
||||
android:visible="false"
|
||||
app:showAsAction="always" />
|
||||
|
||||
<item
|
||||
android:id="@+id/action_save_page"
|
||||
android:title="@string/save_page"
|
||||
android:visible="false"
|
||||
app:showAsAction="never" />
|
||||
|
||||
<item
|
||||
android:id="@+id/action_settings"
|
||||
android:title="@string/settings"
|
||||
app:showAsAction="never" />
|
||||
|
||||
</menu>
|
||||
|
||||
@@ -356,4 +356,5 @@
|
||||
<string name="enter_email_text">Enter your email to continue</string>
|
||||
<string name="removed_from_favourites">Removed from favourites</string>
|
||||
<string name="removed_from_s">Removed from \"%s\"</string>
|
||||
<string name="options">Options</string>
|
||||
</resources>
|
||||
|
||||
@@ -21,6 +21,7 @@ class JsonSerializerTest {
|
||||
categoryId = 20,
|
||||
sortKey = 1,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
deletedAt = 0L,
|
||||
)
|
||||
val json = JsonSerializer(entity).toJson()
|
||||
val result = JsonDeserializer(json).toFavouriteEntity()
|
||||
@@ -71,6 +72,7 @@ class JsonSerializerTest {
|
||||
page = 35,
|
||||
scroll = 24.0f,
|
||||
percent = 0.6f,
|
||||
deletedAt = 0L,
|
||||
)
|
||||
val json = JsonSerializer(entity).toJson()
|
||||
val result = JsonDeserializer(json).toHistoryEntity()
|
||||
@@ -87,9 +89,10 @@ class JsonSerializerTest {
|
||||
order = SortOrder.RATING.name,
|
||||
track = false,
|
||||
isVisibleInLibrary = true,
|
||||
deletedAt = 0L,
|
||||
)
|
||||
val json = JsonSerializer(entity).toJson()
|
||||
val result = JsonDeserializer(json).toFavouriteCategoryEntity()
|
||||
assertEquals(entity, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package org.koitharu.kotatsu.reader.domain
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
||||
import kotlin.random.Random
|
||||
|
||||
class ChapterPagesTest {
|
||||
|
||||
@Test
|
||||
fun getChaptersSize() {
|
||||
val pages = ChapterPages()
|
||||
pages.addFirst(1L, List(12) { page(1L) })
|
||||
pages.addFirst(2L, List(17) { page(2L) })
|
||||
assertEquals(2, pages.chaptersSize)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun removeFirst() {
|
||||
val pages = ChapterPages()
|
||||
pages.addLast(1L, List(12) { page(1L) })
|
||||
pages.addLast(2L, List(17) { page(2L) })
|
||||
pages.addLast(4L, List(2) { page(4L) })
|
||||
pages.removeFirst()
|
||||
assertEquals(2, pages.chaptersSize)
|
||||
assertEquals(17 + 2, pages.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun removeLast() {
|
||||
val pages = ChapterPages()
|
||||
pages.addLast(1L, List(12) { page(1L) })
|
||||
pages.addLast(2L, List(17) { page(2L) })
|
||||
pages.addLast(4L, List(2) { page(4L) })
|
||||
pages.removeLast()
|
||||
assertEquals(2, pages.chaptersSize)
|
||||
assertEquals(12 + 17, pages.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clear() {
|
||||
val pages = ChapterPages()
|
||||
pages.addLast(1L, List(12) { page(1L) })
|
||||
pages.addLast(2L, List(17) { page(2L) })
|
||||
pages.addLast(4L, List(2) { page(4L) })
|
||||
pages.clear()
|
||||
assertEquals(0, pages.chaptersSize)
|
||||
assertEquals(0, pages.size)
|
||||
assertEquals(0, pages.size(1L))
|
||||
assertEquals(0, pages.size(2L))
|
||||
assertEquals(0, pages.size(4L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun subList() {
|
||||
val pages = ChapterPages()
|
||||
pages.addLast(1L, List(12) { page(1L) })
|
||||
pages.addLast(2L, List(17) { page(2L) })
|
||||
pages.addFirst(4L, List(2) { page(4L) })
|
||||
val subList = pages.subList(2L)
|
||||
assertEquals(17, subList.size)
|
||||
assertEquals(2L, subList.first().chapterId)
|
||||
assertEquals(2L, subList.last().chapterId)
|
||||
assertTrue(subList.all { it.chapterId == 2L })
|
||||
assertEquals(subList.size, pages.size(2L))
|
||||
}
|
||||
|
||||
private fun page(chapterId: Long) = ReaderPage(
|
||||
id = Random.nextLong(),
|
||||
url = "http://localhost",
|
||||
referer = "http://localhost",
|
||||
preview = null,
|
||||
chapterId = chapterId,
|
||||
index = Random.nextInt(),
|
||||
source = MangaSource.DUMMY,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user