Slider in reader #204

pull/189/head
Koitharu 4 years ago
parent efffbab4a7
commit 9c94a273ea
No known key found for this signature in database
GPG Key ID: 8E861F8CE6E7CE27

@ -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.WindowInsetsCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.transition.Slide import androidx.transition.Slide
import androidx.transition.TransitionManager import androidx.transition.TransitionManager
@ -20,8 +19,6 @@ import androidx.transition.TransitionSet
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koin.android.ext.android.get 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.reader.ui.thumbnails.PagesThumbnailsSheet
import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.utils.GridTouchHelper import org.koitharu.kotatsu.utils.GridTouchHelper
import org.koitharu.kotatsu.utils.ScreenOrientationHelper
import org.koitharu.kotatsu.utils.ShareHelper import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.*
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@ -68,7 +64,6 @@ class ReaderActivity :
} }
private lateinit var touchHelper: GridTouchHelper private lateinit var touchHelper: GridTouchHelper
private lateinit var orientationHelper: ScreenOrientationHelper
private lateinit var controlDelegate: ReaderControlDelegate private lateinit var controlDelegate: ReaderControlDelegate
private val savePageRequest = registerForActivityResult(PageSaveContract(), this) private val savePageRequest = registerForActivityResult(PageSaveContract(), this)
private var gestureInsets: Insets = Insets.NONE private var gestureInsets: Insets = Insets.NONE
@ -81,18 +76,12 @@ class ReaderActivity :
readerManager = ReaderManager(supportFragmentManager, R.id.container) readerManager = ReaderManager(supportFragmentManager, R.id.container)
supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayHomeAsUpEnabled(true)
touchHelper = GridTouchHelper(this, this) touchHelper = GridTouchHelper(this, this)
orientationHelper = ScreenOrientationHelper(this)
controlDelegate = ReaderControlDelegate(lifecycleScope, get(), this) controlDelegate = ReaderControlDelegate(lifecycleScope, get(), this)
binding.toolbarBottom.inflateMenu(R.menu.opt_reader_bottom)
binding.toolbarBottom.setOnMenuItemClickListener(::onOptionsItemSelected) binding.toolbarBottom.setOnMenuItemClickListener(::onOptionsItemSelected)
binding.slider.setLabelFormatter(PageLabelFormatter())
ReaderSliderListener(this, viewModel).attachToSlider(binding.slider)
insetsDelegate.interceptingWindowInsetsListener = this 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.onError.observe(this, this::onError)
viewModel.readerMode.observe(this, this::onInitReader) viewModel.readerMode.observe(this, this::onInitReader)
viewModel.onPageSaved.observe(this, this::onPageSaved) viewModel.onPageSaved.observe(this, this::onPageSaved)
@ -114,15 +103,6 @@ class ReaderActivity :
if (readerManager.currentMode != mode) { if (readerManager.currentMode != mode) {
readerManager.replace(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) { if (binding.appbarTop.isVisible) {
lifecycle.postDelayed(hideUiRunnable, TimeUnit.SECONDS.toMillis(1)) lifecycle.postDelayed(hideUiRunnable, TimeUnit.SECONDS.toMillis(1))
} }
@ -136,7 +116,7 @@ class ReaderActivity :
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) { when (item.itemId) {
R.id.action_reader_mode -> { R.id.action_menu -> {
val currentMode = readerManager.currentMode ?: return false val currentMode = readerManager.currentMode ?: return false
ReaderConfigDialog.show(supportFragmentManager, currentMode) ReaderConfigDialog.show(supportFragmentManager, currentMode)
} }
@ -150,9 +130,6 @@ class ReaderActivity :
viewModel.getCurrentState()?.chapterId ?: 0L viewModel.getCurrentState()?.chapterId ?: 0L
) )
} }
R.id.action_screen_rotate -> {
orientationHelper.toggleOrientation()
}
R.id.action_pages_thumbs -> { R.id.action_pages_thumbs -> {
val pages = viewModel.getCurrentChapterPages() val pages = viewModel.getCurrentChapterPages()
if (!pages.isNullOrEmpty()) { if (!pages.isNullOrEmpty()) {
@ -166,12 +143,6 @@ class ReaderActivity :
return false 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 -> { R.id.action_bookmark -> {
if (viewModel.isBookmarkAdded.value == true) { if (viewModel.isBookmarkAdded.value == true) {
viewModel.removeBookmark() viewModel.removeBookmark()
@ -199,7 +170,6 @@ class ReaderActivity :
val menu = binding.toolbarBottom.menu val menu = binding.toolbarBottom.menu
menu.findItem(R.id.action_bookmark).isVisible = hasPages menu.findItem(R.id.action_bookmark).isVisible = hasPages
menu.findItem(R.id.action_pages_thumbs).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) { private fun onError(e: Throwable) {
@ -351,6 +321,7 @@ class ReaderActivity :
title = uiState?.chapterName ?: uiState?.mangaName ?: getString(R.string.loading_) title = uiState?.chapterName ?: uiState?.mangaName ?: getString(R.string.loading_)
if (uiState == null) { if (uiState == null) {
supportActionBar?.subtitle = null supportActionBar?.subtitle = null
binding.slider.isVisible = false
return return
} }
supportActionBar?.subtitle = if (uiState.chapterNumber in 1..uiState.chaptersTotal) { supportActionBar?.subtitle = if (uiState.chapterNumber in 1..uiState.chaptersTotal) {
@ -363,6 +334,13 @@ class ReaderActivity :
binding.toastView.showTemporary(uiState.chapterName, TOAST_DURATION) 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( 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.net.Uri
import android.util.LongSparseArray import android.util.LongSparseArray
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.AnyThread
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope 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.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.reader.data.filterChapters 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.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.processLifecycleScope import org.koitharu.kotatsu.utils.ext.processLifecycleScope
import org.koitharu.kotatsu.utils.ext.requireValue
import java.util.* import java.util.*
private const val BOUNDS_PAGE_OFFSET = 2 private const val BOUNDS_PAGE_OFFSET = 2
private const val PAGES_TRIM_THRESHOLD = 120
private const val PREFETCH_LIMIT = 10 private const val PREFETCH_LIMIT = 10
class ReaderViewModel( class ReaderViewModel(
@ -53,25 +54,16 @@ class ReaderViewModel(
private var bookmarkJob: Job? = null private var bookmarkJob: Job? = null
private val currentState = MutableStateFlow(initialState) private val currentState = MutableStateFlow(initialState)
private val mangaData = MutableStateFlow(intent.manga) private val mangaData = MutableStateFlow(intent.manga)
private val chapters = LongSparseArray<MangaChapter>() private val chapters: LongSparseArray<MangaChapter>
get() = chaptersLoader.chapters
val pageLoader = PageLoader() val pageLoader = PageLoader()
private val chaptersLoader = ChaptersLoader()
val readerMode = MutableLiveData<ReaderMode>() val readerMode = MutableLiveData<ReaderMode>()
val onPageSaved = SingleLiveEvent<Uri?>() val onPageSaved = SingleLiveEvent<Uri?>()
val onShowToast = SingleLiveEvent<Int>() val onShowToast = SingleLiveEvent<Int>()
val uiState: LiveData<ReaderUiState?> = combine( val uiState = MutableLiveData<ReaderUiState?>(null)
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 content = MutableLiveData(ReaderContent(emptyList(), null)) val content = MutableLiveData(ReaderContent(emptyList(), null))
val manga: Manga? val manga: Manga?
@ -134,7 +126,6 @@ class ReaderViewModel(
} }
} }
// TODO check performance
fun saveCurrentState(state: ReaderState? = null) { fun saveCurrentState(state: ReaderState? = null) {
if (state != null) { if (state != null) {
currentState.value = state currentState.value = state
@ -151,8 +142,7 @@ class ReaderViewModel(
fun getCurrentChapterPages(): List<MangaPage>? { fun getCurrentChapterPages(): List<MangaPage>? {
val chapterId = currentState.value?.chapterId ?: return null val chapterId = currentState.value?.chapterId ?: return null
val pages = content.value?.pages ?: return null return chaptersLoader.getPages(chapterId).map { it.toMangaPage() }
return pages.filter { it.chapterId == chapterId }.map { it.toMangaPage() }
} }
fun saveCurrentPage( fun saveCurrentPage(
@ -195,11 +185,12 @@ class ReaderViewModel(
loadingJob = launchLoadingJob(Dispatchers.Default) { loadingJob = launchLoadingJob(Dispatchers.Default) {
prevJob?.cancelAndJoin() prevJob?.cancelAndJoin()
content.postValue(ReaderContent(emptyList(), null)) content.postValue(ReaderContent(emptyList(), null))
val newPages = loadChapter(id) chaptersLoader.loadSingleChapter(mangaData.requireValue(), id)
content.postValue(ReaderContent(newPages, ReaderState(id, 0, 0))) content.postValue(ReaderContent(chaptersLoader.snapshot(), ReaderState(id, 0, 0)))
} }
} }
// TODO move to background?
fun onCurrentPageChanged(position: Int) { fun onCurrentPageChanged(position: Int) {
val pages = content.value?.pages ?: return val pages = content.value?.pages ?: return
pages.getOrNull(position)?.let { page -> pages.getOrNull(position)?.let { page ->
@ -207,14 +198,15 @@ class ReaderViewModel(
cs?.copy(chapterId = page.chapterId, page = page.index) cs?.copy(chapterId = page.chapterId, page = page.index)
} }
} }
notifyStateChanged()
if (pages.isEmpty() || loadingJob?.isActive == true) { if (pages.isEmpty() || loadingJob?.isActive == true) {
return return
} }
if (position <= BOUNDS_PAGE_OFFSET) { if (position <= BOUNDS_PAGE_OFFSET) {
loadPrevNextChapter(pages.first().chapterId, -1) loadPrevNextChapter(pages.first().chapterId, isNext = false)
} }
if (position >= pages.size - BOUNDS_PAGE_OFFSET) { if (position >= pages.size - BOUNDS_PAGE_OFFSET) {
loadPrevNextChapter(pages.last().chapterId, 1) loadPrevNextChapter(pages.last().chapterId, isNext = true)
} }
if (pageLoader.isPrefetchApplicable()) { if (pageLoader.isPrefetchApplicable()) {
pageLoader.prefetch(pages.trySublist(position + 1, position + PREFETCH_LIMIT)) pageLoader.prefetch(pages.trySublist(position + 1, position + PREFETCH_LIMIT))
@ -279,55 +271,21 @@ class ReaderViewModel(
mangaData.value = manga.filterChapters(branch) mangaData.value = manga.filterChapters(branch)
readerMode.postValue(mode) readerMode.postValue(mode)
val pages = loadChapter(requireNotNull(currentState.value).chapterId) chaptersLoader.loadSingleChapter(manga, requireNotNull(currentState.value).chapterId)
// save state // save state
currentState.value?.let { currentState.value?.let {
val percent = computePercent(it.chapterId, it.page) val percent = computePercent(it.chapterId, it.page)
historyRepository.addOrUpdate(manga, it.chapterId, it.page, it.scroll, percent) historyRepository.addOrUpdate(manga, it.chapterId, it.page, it.scroll, percent)
} }
notifyStateChanged()
content.postValue(ReaderContent(pages, currentState.value)) 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) { loadingJob = launchLoadingJob(Dispatchers.Default) {
val chapters = mangaData.value?.chapters ?: return@launchLoadingJob chaptersLoader.loadPrevNextChapter(mangaData.requireValue(), currentId, isNext)
val predicate: (MangaChapter) -> Boolean = { it.id == currentId } content.postValue(ReaderContent(chaptersLoader.snapshot(), null))
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))
} }
} }
@ -368,12 +326,26 @@ class ReaderViewModel(
}.getOrDefault(defaultMode) }.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 { private fun computePercent(chapterId: Long, pageIndex: Int): Float {
val chapters = manga?.chapters ?: return PROGRESS_NONE val chapters = manga?.chapters ?: return PROGRESS_NONE
val chaptersCount = chapters.size val chaptersCount = chapters.size
val chapterIndex = chapters.indexOfFirst { x -> x.id == chapterId } val chapterIndex = chapters.indexOfFirst { x -> x.id == chapterId }
val pages = content.value?.pages ?: return PROGRESS_NONE val pagesCount = chaptersLoader.getPagesCount(chapterId)
val pagesCount = pages.count { x -> x.chapterId == chapterId }
if (chaptersCount == 0 || pagesCount == 0) { if (chaptersCount == 0 || pagesCount == 0) {
return PROGRESS_NONE return PROGRESS_NONE
} }
@ -401,4 +373,4 @@ private fun HistoryRepository.saveStateAsync(manga: Manga, state: ReaderState, p
it.printStackTraceDebug() it.printStackTraceDebug()
} }
} }
} }

@ -4,5 +4,7 @@ data class ReaderUiState(
val mangaName: String?, val mangaName: String?,
val chapterName: String?, val chapterName: String?,
val chapterNumber: Int, 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 android.os.SystemClock
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.transformLatest
fun <T> Flow<T>.onFirst(action: suspend (T) -> Unit): Flow<T> { fun <T> Flow<T>.onFirst(action: suspend (T) -> Unit): Flow<T> {
var isFirstCall = true var isFirstCall = true
@ -34,4 +31,8 @@ fun <T> Flow<T>.throttle(timeoutMillis: (T) -> Long): Flow<T> {
emit(value) emit(value)
lastEmittedAt = now 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_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="top" 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> </com.google.android.material.appbar.AppBarLayout>
@ -86,4 +98,4 @@
</LinearLayout> </LinearLayout>
</FrameLayout> </FrameLayout>

@ -20,28 +20,9 @@
app:showAsAction="always" /> app:showAsAction="always" />
<item <item
android:id="@+id/action_screen_rotate" android:id="@+id/action_menu"
android:icon="@drawable/ic_screen_rotation" android:icon="@drawable/abc_ic_menu_overflow_material"
android:title="@string/rotate_screen" android:title="@string/options"
android:visible="false"
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" /> 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> </menu>

@ -356,4 +356,5 @@
<string name="enter_email_text">Enter your email to continue</string> <string name="enter_email_text">Enter your email to continue</string>
<string name="removed_from_favourites">Removed from favourites</string> <string name="removed_from_favourites">Removed from favourites</string>
<string name="removed_from_s">Removed from \"%s\"</string> <string name="removed_from_s">Removed from \"%s\"</string>
<string name="options">Options</string>
</resources> </resources>

@ -21,6 +21,7 @@ class JsonSerializerTest {
categoryId = 20, categoryId = 20,
sortKey = 1, sortKey = 1,
createdAt = System.currentTimeMillis(), createdAt = System.currentTimeMillis(),
deletedAt = 0L,
) )
val json = JsonSerializer(entity).toJson() val json = JsonSerializer(entity).toJson()
val result = JsonDeserializer(json).toFavouriteEntity() val result = JsonDeserializer(json).toFavouriteEntity()
@ -71,6 +72,7 @@ class JsonSerializerTest {
page = 35, page = 35,
scroll = 24.0f, scroll = 24.0f,
percent = 0.6f, percent = 0.6f,
deletedAt = 0L,
) )
val json = JsonSerializer(entity).toJson() val json = JsonSerializer(entity).toJson()
val result = JsonDeserializer(json).toHistoryEntity() val result = JsonDeserializer(json).toHistoryEntity()
@ -87,9 +89,10 @@ class JsonSerializerTest {
order = SortOrder.RATING.name, order = SortOrder.RATING.name,
track = false, track = false,
isVisibleInLibrary = true, isVisibleInLibrary = true,
deletedAt = 0L,
) )
val json = JsonSerializer(entity).toJson() val json = JsonSerializer(entity).toJson()
val result = JsonDeserializer(json).toFavouriteCategoryEntity() val result = JsonDeserializer(json).toFavouriteCategoryEntity()
assertEquals(entity, result) 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,
)
}
Loading…
Cancel
Save