Refactor reader

This commit is contained in:
Koitharu
2020-12-16 08:25:43 +02:00
parent 71a5801a0c
commit 904d12f611
80 changed files with 1239 additions and 1132 deletions

View File

@@ -32,12 +32,14 @@ abstract class BaseFragment<B : ViewBinding> : Fragment() {
open fun getTitle(): CharSequence? = null
protected abstract fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): B
override fun onAttach(context: Context) {
super.onAttach(context)
getTitle()?.let {
activity?.title = it
}
}
}
protected fun bindingOrNull() = viewBinding
protected abstract fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): B
}

View File

@@ -4,6 +4,7 @@ import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import org.koin.core.component.KoinComponent
@Deprecated("")
abstract class BaseViewHolder<T, E, B : ViewBinding> protected constructor(val binding: B) :
RecyclerView.ViewHolder(binding.root), KoinComponent {

View File

@@ -20,6 +20,7 @@ import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.download.DownloadService
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState
class ChaptersFragment : BaseFragment<FragmentChaptersBinding>(),
OnListItemClickListener<MangaChapter>, ActionMode.Callback {
@@ -84,9 +85,9 @@ class ChaptersFragment : BaseFragment<FragmentChaptersBinding>(),
)
startActivity(
ReaderActivity.newIntent(
context ?: return,
view.context,
viewModel.manga.value ?: return,
item.id
ReaderState(item.id, 0, 0)
), options.toBundle()
)
}

View File

@@ -209,12 +209,14 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(),
const val ACTION_MANGA_VIEW = "${BuildConfig.APPLICATION_ID}.action.VIEW_MANGA"
fun newIntent(context: Context, manga: Manga) =
Intent(context, DetailsActivity::class.java)
fun newIntent(context: Context, manga: Manga): Intent {
return Intent(context, DetailsActivity::class.java)
.putExtra(MangaIntent.KEY_MANGA, manga)
}
fun newIntent(context: Context, mangaId: Long) =
Intent(context, DetailsActivity::class.java)
fun newIntent(context: Context, mangaId: Long): Intent {
return Intent(context, DetailsActivity::class.java)
.putExtra(MangaIntent.KEY_ID, mangaId)
}
}
}

View File

@@ -22,6 +22,7 @@ import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.databinding.FragmentDetailsBinding
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesDialog
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.utils.FileSizeUtils
import org.koitharu.kotatsu.utils.ext.*
import kotlin.math.roundToInt
@@ -138,7 +139,7 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
ReaderActivity.newIntent(
context ?: return,
manga ?: return,
viewModel.readingHistory.value
null
)
)
}
@@ -157,7 +158,10 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
startActivity(
ReaderActivity.newIntent(
context ?: return@showPopupMenu false,
viewModel.manga.value ?: return@showPopupMenu false
viewModel.manga.value ?: return@showPopupMenu false,
viewModel.chapters.value?.firstOrNull()?.let { c ->
ReaderState(c.chapter.id, 0, 0)
}
)
)
true

View File

@@ -4,6 +4,7 @@ import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.base.ui.BaseViewModel
@@ -34,28 +35,28 @@ class DetailsViewModel(
.distinctUntilChanged()
.flatMapLatest { mangaId ->
historyRepository.observeOne(mangaId)
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
private val favourite = mangaData.mapNotNull { it?.id }
.distinctUntilChanged()
.flatMapLatest { mangaId ->
favouritesRepository.observeCategoriesIds(mangaId).map { it.isNotEmpty() }
}.stateIn(viewModelScope, SharingStarted.Eagerly, false)
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
private val newChapters = mangaData.mapNotNull { it?.id }
.distinctUntilChanged()
.mapLatest { mangaId ->
trackingRepository.getNewChaptersCount(mangaId)
}.stateIn(viewModelScope, SharingStarted.Eagerly, 0)
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0)
val manga = mangaData.filterNotNull()
.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
.asLiveData(viewModelScope.coroutineContext)
val favouriteCategories = favourite
.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
.asLiveData(viewModelScope.coroutineContext)
val newChaptersCount = newChapters
.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
.asLiveData(viewModelScope.coroutineContext)
val readingHistory = history
.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
.asLiveData(viewModelScope.coroutineContext)
val onMangaRemoved = SingleLiveEvent<Manga>()
@@ -76,7 +77,7 @@ class DetailsViewModel(
}
)
}
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
}.flowOn(Dispatchers.Default).asLiveData(viewModelScope.coroutineContext)
init {
launchLoadingJob(Dispatchers.Default) {

View File

@@ -30,7 +30,6 @@ class FavouritesContainerFragment : BaseFragment<FragmentFavouritesBinding>(),
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
adapterState = savedInstanceState?.getParcelable(KEY_ADAPTER_STATE) ?: adapterState
}
override fun onInflateView(
@@ -41,7 +40,6 @@ class FavouritesContainerFragment : BaseFragment<FragmentFavouritesBinding>(),
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val adapter = FavouritesPagerAdapter(this, this)
adapterState?.let(adapter::restoreState)
binding.pager.adapter = adapter
TabLayoutMediator(binding.tabs, binding.pager, adapter).attach()
@@ -49,6 +47,13 @@ class FavouritesContainerFragment : BaseFragment<FragmentFavouritesBinding>(),
viewModel.onError.observe(viewLifecycleOwner, ::onError)
}
override fun onViewStateRestored(savedInstanceState: Bundle?) {
super.onViewStateRestored(savedInstanceState)
(savedInstanceState?.getParcelable(KEY_ADAPTER_STATE) ?: adapterState)?.let {
(binding.pager.adapter as FavouritesPagerAdapter).restoreState(it)
}
}
override fun onDestroyView() {
adapterState = (binding.pager.adapter as? FavouritesPagerAdapter)?.saveState()
super.onDestroyView()
@@ -56,6 +61,8 @@ class FavouritesContainerFragment : BaseFragment<FragmentFavouritesBinding>(),
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
adapterState = (bindingOrNull()?.pager?.adapter as? FavouritesPagerAdapter)?.saveState()
?: adapterState
outState.putParcelable(KEY_ADAPTER_STATE, adapterState)
}

View File

@@ -4,6 +4,7 @@ import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.flowOn
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
@@ -14,7 +15,7 @@ class FavouritesCategoriesViewModel(
private var reorderJob: Job? = null
val categories = repository.observeCategories()
.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
.flowOn(Dispatchers.Default).asLiveData(viewModelScope.coroutineContext)
fun createCategory(name: String) {
launchJob(Dispatchers.Default) {

View File

@@ -4,6 +4,7 @@ import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
@@ -25,7 +26,7 @@ class MangaCategoriesViewModel(
isChecked = it.id in checked
)
}
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
}.flowOn(Dispatchers.Default).asLiveData(viewModelScope.coroutineContext)
fun setChecked(categoryId: Long, isChecked: Boolean) {
launchJob(Dispatchers.Default) {

View File

@@ -1,11 +1,10 @@
package org.koitharu.kotatsu.favourites.ui.list
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.flowOn
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.prefs.AppSettings
@@ -15,6 +14,7 @@ import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.utils.ext.asLiveData
import org.koitharu.kotatsu.utils.ext.onFirst
class FavouritesListViewModel(
@@ -41,11 +41,9 @@ class FavouritesListViewModel(
}
}.onFirst {
isLoading.postValue(false)
}.onStart {
emit(listOf(LoadingState))
}.catch {
emit(listOf(it.toErrorState(canRetry = false)))
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
}.flowOn(Dispatchers.Default).asLiveData(viewModelScope.coroutineContext, listOf(LoadingState))
override fun onRefresh() = Unit

View File

@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.history.ui
import android.content.Context
import android.os.Build
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
@@ -18,6 +17,7 @@ import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.utils.MangaShortcut
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveData
import org.koitharu.kotatsu.utils.ext.daysDiff
import org.koitharu.kotatsu.utils.ext.onFirst
import java.util.*
@@ -51,11 +51,9 @@ class HistoryListViewModel(
}
}.onFirst {
isLoading.postValue(false)
}.onStart {
emit(listOf(LoadingState))
}.catch {
it.toErrorState(canRetry = false)
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
}.flowOn(Dispatchers.Default).asLiveData(viewModelScope.coroutineContext, listOf(LoadingState))
override fun onRefresh() = Unit

View File

@@ -22,7 +22,8 @@ abstract class MangaListViewModel(
.filter { it == AppSettings.KEY_GRID_SIZE }
.map { settings.gridSize / 100f }
.onStart { emit(settings.gridSize / 100f) }
.asLiveData(viewModelScope.coroutineContext + Dispatchers.IO)
.flowOn(Dispatchers.IO)
.asLiveData(viewModelScope.coroutineContext)
protected fun createListModeFlow() = settings.observe()
.filter { it == AppSettings.KEY_LIST_MODE }

View File

@@ -8,6 +8,7 @@ import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R
@@ -52,7 +53,7 @@ class LocalListViewModel(
}
}.onStart {
emit(listOf(LoadingState))
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
}.flowOn(Dispatchers.Default).asLiveData(viewModelScope.coroutineContext)
init {
onRefresh()

View File

@@ -18,6 +18,7 @@ import com.google.android.material.snackbar.Snackbar
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.prefs.AppSection
import org.koitharu.kotatsu.databinding.ActivityMainBinding
@@ -26,7 +27,6 @@ import org.koitharu.kotatsu.history.ui.HistoryListFragment
import org.koitharu.kotatsu.local.ui.LocalListFragment
import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
import org.koitharu.kotatsu.search.ui.SearchHelper
import org.koitharu.kotatsu.settings.AppUpdateChecker
@@ -159,7 +159,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
return true
}
private fun onOpenReader(state: ReaderState) {
private fun onOpenReader(manga: Manga) {
val options = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
ActivityOptions.makeClipRevealAnimation(
binding.fab, 0, 0, binding.fab.measuredWidth, binding.fab.measuredHeight
@@ -169,7 +169,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
binding.fab, 0, 0, binding.fab.measuredWidth, binding.fab.measuredHeight
)
}
startActivity(ReaderActivity.newIntent(this, state), options?.toBundle())
startActivity(ReaderActivity.newIntent(this, manga, null), options?.toBundle())
}
private fun onError(e: Throwable) {

View File

@@ -3,16 +3,13 @@ package org.koitharu.kotatsu.main.ui
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.*
import org.koitharu.kotatsu.base.domain.MangaProviderFactory
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.utils.SingleLiveEvent
class MainViewModel(
@@ -20,7 +17,7 @@ class MainViewModel(
settings: AppSettings
) : BaseViewModel() {
val onOpenReader = SingleLiveEvent<ReaderState>()
val onOpenReader = SingleLiveEvent<Manga>()
var defaultSection by settings::defaultSection
val remoteSources = settings.observe()
@@ -28,18 +25,14 @@ class MainViewModel(
.onStart { emit("") }
.map { MangaProviderFactory.getSources(settings, includeHidden = false) }
.distinctUntilChanged()
.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
.flowOn(Dispatchers.Default)
.asLiveData(viewModelScope.coroutineContext)
fun openLastReader() {
launchLoadingJob {
val manga = historyRepository.getList(0, 1).firstOrNull()
?: throw EmptyHistoryException()
val history = historyRepository.getOne(manga) ?: throw EmptyHistoryException()
val state = ReaderState(
manga.source.repository.getDetails(manga),
history.chapterId, history.page, history.scroll
)
onOpenReader.call(state)
onOpenReader.call(manga)
}
}
}

View File

@@ -7,6 +7,7 @@ import org.koin.core.component.inject
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.main.ui.MainActivity
@Deprecated("TODO not object")
object AppProtectHelper : KoinComponent {
val settings by inject<AppSettings>()

View File

@@ -0,0 +1,95 @@
package org.koitharu.kotatsu.reader
import android.view.KeyEvent
import androidx.lifecycle.LifecycleCoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.utils.GridTouchHelper
@Suppress("UNUSED_PARAMETER")
class ReaderControlDelegate(
private val scope: LifecycleCoroutineScope,
private val settings: AppSettings,
private val listener: OnInteractionListener
) {
private var isTapSwitchEnabled: Boolean = true
private var isVolumeKeysSwitchEnabled: Boolean = false
init {
settings.observe()
.filter { it == AppSettings.KEY_READER_SWITCHERS }
.map { settings.readerPageSwitch }
.onStart { emit(settings.readerPageSwitch) }
.distinctUntilChanged()
.flowOn(Dispatchers.IO)
.onEach {
isTapSwitchEnabled = AppSettings.PAGE_SWITCH_TAPS in it
isVolumeKeysSwitchEnabled = AppSettings.PAGE_SWITCH_VOLUME_KEYS in it
}.launchIn(scope)
}
fun onGridTouch(area: Int) {
when (area) {
GridTouchHelper.AREA_CENTER -> {
listener.toggleUiVisibility()
}
GridTouchHelper.AREA_TOP,
GridTouchHelper.AREA_LEFT -> if (isTapSwitchEnabled) {
listener.switchPageBy(-1)
}
GridTouchHelper.AREA_BOTTOM,
GridTouchHelper.AREA_RIGHT -> if (isTapSwitchEnabled) {
listener.switchPageBy(1)
}
}
}
fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean = when (keyCode) {
KeyEvent.KEYCODE_VOLUME_UP -> if (isVolumeKeysSwitchEnabled) {
listener.switchPageBy(-1)
true
} else {
false
}
KeyEvent.KEYCODE_VOLUME_DOWN -> if (isVolumeKeysSwitchEnabled) {
listener.switchPageBy(1)
true
} else {
false
}
KeyEvent.KEYCODE_SPACE,
KeyEvent.KEYCODE_PAGE_DOWN,
KeyEvent.KEYCODE_SYSTEM_NAVIGATION_DOWN,
KeyEvent.KEYCODE_DPAD_DOWN,
KeyEvent.KEYCODE_DPAD_RIGHT -> {
listener.switchPageBy(1)
true
}
KeyEvent.KEYCODE_PAGE_UP,
KeyEvent.KEYCODE_SYSTEM_NAVIGATION_UP,
KeyEvent.KEYCODE_DPAD_UP,
KeyEvent.KEYCODE_DPAD_LEFT -> {
listener.switchPageBy(-1)
true
}
KeyEvent.KEYCODE_DPAD_CENTER -> {
listener.toggleUiVisibility()
true
}
else -> false
}
fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean {
return (isVolumeKeysSwitchEnabled &&
(keyCode == KeyEvent.KEYCODE_VOLUME_DOWN || keyCode == KeyEvent.KEYCODE_VOLUME_UP))
}
interface OnInteractionListener {
fun switchPageBy(delta: Int)
fun toggleUiVisibility()
}
}

View File

@@ -3,7 +3,9 @@ package org.koitharu.kotatsu.reader
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.ReaderViewModel
val readerModule
@@ -12,5 +14,7 @@ val readerModule
single { MangaDataRepository(get()) }
single { PagesCache(get()) }
viewModel { ReaderViewModel(get(), get()) }
viewModel { (intent: MangaIntent, state: ReaderState?) ->
ReaderViewModel(intent, state, get(), get(), get())
}
}

View File

@@ -9,26 +9,21 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.OkHttpClient
import okhttp3.Request
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.utils.CacheUtils
import org.koitharu.kotatsu.utils.ext.await
import java.io.File
import java.util.zip.ZipFile
import kotlin.coroutines.CoroutineContext
class PageLoader : KoinComponent, CoroutineScope, DisposableHandle {
class PageLoader(
scope: CoroutineScope,
private val okHttp: OkHttpClient,
private val cache: PagesCache
) : CoroutineScope by scope {
private val job = SupervisorJob()
private val tasks = ArrayMap<String, Deferred<File>>()
private val okHttp by inject<OkHttpClient>()
private val cache by inject<PagesCache>()
private val convertLock = Mutex()
override val coroutineContext: CoroutineContext
get() = job + Dispatchers.Main.immediate
@Suppress("BlockingMethodInNonBlockingContext")
suspend fun loadFile(url: String, force: Boolean): File {
if (!force) {
@@ -74,7 +69,7 @@ class PageLoader : KoinComponent, CoroutineScope, DisposableHandle {
suspend fun convertInPlace(file: File) {
convertLock.withLock(file) {
withContext(Dispatchers.IO) {
withContext(Dispatchers.Default) {
val image = BitmapFactory.decodeFile(file.absolutePath)
try {
file.outputStream().use { out ->
@@ -86,9 +81,4 @@ class PageLoader : KoinComponent, CoroutineScope, DisposableHandle {
}
}
}
override fun dispose() {
job.cancelChildren()
tasks.clear()
}
}

View File

@@ -3,10 +3,8 @@ package org.koitharu.kotatsu.reader.ui
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.view.*
import android.widget.Toast
@@ -20,53 +18,54 @@ import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
import kotlinx.coroutines.withContext
import org.koin.android.ext.android.get
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.base.ui.BaseFullscreenActivity
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.core.model.MangaPage
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.databinding.ActivityReaderBinding
import org.koitharu.kotatsu.reader.ui.base.AbstractReader
import org.koitharu.kotatsu.reader.ui.reversed.ReversedReaderFragment
import org.koitharu.kotatsu.reader.ui.standard.PagerReaderFragment
import org.koitharu.kotatsu.reader.ReaderControlDelegate
import org.koitharu.kotatsu.reader.ui.pager.BaseReader
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
import org.koitharu.kotatsu.reader.ui.pager.reversed.ReversedReaderFragment
import org.koitharu.kotatsu.reader.ui.pager.standard.PagerReaderFragment
import org.koitharu.kotatsu.reader.ui.pager.wetoon.WebtoonReaderFragment
import org.koitharu.kotatsu.reader.ui.thumbnails.OnPageSelectListener
import org.koitharu.kotatsu.reader.ui.thumbnails.PagesThumbnailsSheet
import org.koitharu.kotatsu.reader.ui.wetoon.WebtoonReaderFragment
import org.koitharu.kotatsu.utils.GridTouchHelper
import org.koitharu.kotatsu.utils.MangaShortcut
import org.koitharu.kotatsu.utils.ScreenOrientationHelper
import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.anim.Motion
import org.koitharu.kotatsu.utils.ext.*
import org.koitharu.kotatsu.utils.ext.hasGlobalPoint
import org.koitharu.kotatsu.utils.ext.hideAnimated
import org.koitharu.kotatsu.utils.ext.hitTest
import org.koitharu.kotatsu.utils.ext.showAnimated
class ReaderActivity : BaseFullscreenActivity<ActivityReaderBinding>(),
ChaptersDialog.OnChapterChangeListener,
GridTouchHelper.OnGridTouchListener, OnPageSelectListener, ReaderConfigDialog.Callback,
ReaderListener, SharedPreferences.OnSharedPreferenceChangeListener,
ActivityResultCallback<Boolean>, OnApplyWindowInsetsListener {
ActivityResultCallback<Boolean>, OnApplyWindowInsetsListener,
ReaderControlDelegate.OnInteractionListener {
private val viewModel by viewModel<ReaderViewModel>()
private val settings by inject<AppSettings>()
lateinit var state: ReaderState
private set
private val viewModel by viewModel<ReaderViewModel> {
parametersOf(MangaIntent.from(intent), intent?.getParcelableExtra<ReaderState>(EXTRA_STATE))
}
private lateinit var touchHelper: GridTouchHelper
private lateinit var orientationHelper: ScreenOrientationHelper
private var isTapSwitchEnabled = true
private var isVolumeKeysSwitchEnabled = false
private lateinit var controlDelegate: ReaderControlDelegate
private val reader
get() = supportFragmentManager.findFragmentById(R.id.container) as? AbstractReader<*>
get() = supportFragmentManager.findFragmentById(R.id.container) as? BaseReader<*>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -74,63 +73,43 @@ class ReaderActivity : BaseFullscreenActivity<ActivityReaderBinding>(),
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)
@Suppress("RemoveExplicitTypeArguments")
state = savedInstanceState?.getParcelable<ReaderState>(EXTRA_STATE)
?: intent.getParcelableExtra<ReaderState>(EXTRA_STATE)
?: let {
Toast.makeText(this, R.string.error_occurred, Toast.LENGTH_SHORT).show()
finishAfterTransition()
return
}
title = state.chapter?.name ?: state.manga.title
state.manga.chapters?.run {
supportActionBar?.subtitle =
getString(R.string.chapter_d_of_d, state.chapter?.number ?: 0, size)
}
ViewCompat.setOnApplyWindowInsetsListener(binding.rootLayout, this)
settings.subscribe(this)
loadSwitchSettings()
orientationHelper.observeAutoOrientation()
.onEach {
binding.toolbarBottom.menu.findItem(R.id.action_screen_rotate).isVisible = !it
}.launchIn(lifecycleScope)
if (savedInstanceState == null) {
viewModel.init(state.manga)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
GlobalScope.launch(Dispatchers.Main + IgnoreErrors) {
MangaShortcut(state.manga).addAppShortcut(applicationContext)
}
}
}
viewModel.onError.observe(this, this::onError)
viewModel.reader.observe(this) { (manga, mode) -> onInitReader(manga, mode) }
viewModel.readerMode.observe(this, this::onInitReader)
viewModel.onPageSaved.observe(this, this::onPageSaved)
viewModel.uiState.observe(this, this::onUiStateChanged)
viewModel.isLoading.observe(this, this::onLoadingStateChanged)
viewModel.content.observe(this) {
onLoadingStateChanged(viewModel.isLoading.value == true)
}
}
private fun onInitReader(manga: Manga, mode: ReaderMode) {
private fun onInitReader(mode: ReaderMode) {
val currentReader = reader
when (mode) {
ReaderMode.WEBTOON -> if (currentReader !is WebtoonReaderFragment) {
supportFragmentManager.commit {
replace(R.id.container, WebtoonReaderFragment.newInstance(state))
replace(R.id.container, WebtoonReaderFragment())
}
}
ReaderMode.REVERSED -> if (currentReader !is ReversedReaderFragment) {
supportFragmentManager.commit {
replace(R.id.container, ReversedReaderFragment.newInstance(state))
replace(R.id.container, ReversedReaderFragment())
}
}
ReaderMode.STANDARD -> if (currentReader !is PagerReaderFragment) {
supportFragmentManager.commit {
replace(R.id.container, PagerReaderFragment.newInstance(state))
replace(R.id.container, PagerReaderFragment())
}
}
}
@@ -146,9 +125,9 @@ class ReaderActivity : BaseFullscreenActivity<ActivityReaderBinding>(),
}
}
override fun onDestroy() {
settings.unsubscribe(this)
super.onDestroy()
override fun onPause() {
viewModel.saveCurrentState(reader?.getCurrentState())
super.onPause()
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
@@ -156,11 +135,6 @@ class ReaderActivity : BaseFullscreenActivity<ActivityReaderBinding>(),
return super.onCreateOptionsMenu(menu)
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putParcelable(EXTRA_STATE, state)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_reader_mode -> {
@@ -182,30 +156,28 @@ class ReaderActivity : BaseFullscreenActivity<ActivityReaderBinding>(),
R.id.action_chapters -> {
ChaptersDialog.show(
supportFragmentManager,
state.manga.chapters.orEmpty(),
state.chapterId
viewModel.manga?.chapters.orEmpty(),
viewModel.getCurrentState()?.chapterId ?: 0L
)
}
R.id.action_screen_rotate -> {
orientationHelper.toggleOrientation()
}
R.id.action_pages_thumbs -> {
if (reader?.hasItems == true) {
val pages = reader?.getPages()
if (!pages.isNullOrEmpty()) {
PagesThumbnailsSheet.show(
supportFragmentManager, pages,
state.chapter?.name ?: title?.toString().orEmpty()
)
} else {
showWaitWhileLoading()
}
val pages = viewModel.getCurrentChapterPages()
if (!pages.isNullOrEmpty()) {
PagesThumbnailsSheet.show(
supportFragmentManager,
pages,
title?.toString().orEmpty(),
reader?.getCurrentState()?.page ?: -1
)
} else {
showWaitWhileLoading()
}
}
R.id.action_save_page -> {
if (reader?.hasItems == true) {
if (!viewModel.content.value?.pages.isNullOrEmpty()) {
if (ContextCompat.checkSelfPermission(
this,
Manifest.permission.WRITE_EXTERNAL_STORAGE
@@ -229,30 +201,22 @@ class ReaderActivity : BaseFullscreenActivity<ActivityReaderBinding>(),
override fun onActivityResult(result: Boolean) {
if (result) {
viewModel.savePage(
resolver = contentResolver,
page = reader?.currentPage ?: return
)
viewModel.saveCurrentPage(contentResolver)
}
}
override fun saveState(chapterId: Long, page: Int, scroll: Int) {
state = state.copy(chapterId = chapterId, page = page, scroll = scroll)
ReaderViewModel.saveState(state)
}
override fun onLoadingStateChanged(isLoading: Boolean) {
val hasPages = reader?.hasItems == true
private fun onLoadingStateChanged(isLoading: Boolean) {
val hasPages = !viewModel.content.value?.pages.isNullOrEmpty()
binding.layoutLoading.isVisible = isLoading && !hasPages
binding.progressBarBottom.isVisible = isLoading && hasPages
}
override fun onError(e: Throwable) {
private fun onError(e: Throwable) {
val dialog = AlertDialog.Builder(this)
.setTitle(R.string.error_occurred)
.setMessage(e.message)
.setPositiveButton(R.string.close, null)
if (reader?.hasItems != true) {
if (viewModel.content.value?.pages.isNullOrEmpty()) {
dialog.setOnDismissListener {
finish()
}
@@ -261,19 +225,7 @@ class ReaderActivity : BaseFullscreenActivity<ActivityReaderBinding>(),
}
override fun onGridTouch(area: Int) {
when (area) {
GridTouchHelper.AREA_CENTER -> {
setUiIsVisible(!binding.appbarTop.isVisible)
}
GridTouchHelper.AREA_TOP,
GridTouchHelper.AREA_LEFT -> if (isTapSwitchEnabled) {
reader?.switchPageBy(-1)
}
GridTouchHelper.AREA_BOTTOM,
GridTouchHelper.AREA_RIGHT -> if (isTapSwitchEnabled) {
reader?.switchPageBy(1)
}
}
controlDelegate.onGridTouch(area)
}
override fun onProcessTouch(rawX: Int, rawY: Int): Boolean {
@@ -292,61 +244,32 @@ class ReaderActivity : BaseFullscreenActivity<ActivityReaderBinding>(),
return super.dispatchTouchEvent(ev)
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean = when (keyCode) {
KeyEvent.KEYCODE_VOLUME_UP -> if (isVolumeKeysSwitchEnabled) {
reader?.switchPageBy(-1)
true
} else {
super.onKeyDown(keyCode, event)
}
KeyEvent.KEYCODE_VOLUME_DOWN -> if (isVolumeKeysSwitchEnabled) {
reader?.switchPageBy(1)
true
} else {
super.onKeyDown(keyCode, event)
}
KeyEvent.KEYCODE_SPACE,
KeyEvent.KEYCODE_PAGE_DOWN,
KeyEvent.KEYCODE_DPAD_DOWN,
KeyEvent.KEYCODE_DPAD_RIGHT -> {
reader?.switchPageBy(1)
true
}
KeyEvent.KEYCODE_PAGE_UP,
KeyEvent.KEYCODE_DPAD_UP,
KeyEvent.KEYCODE_DPAD_LEFT -> {
reader?.switchPageBy(-1)
true
}
KeyEvent.KEYCODE_DPAD_CENTER -> {
setUiIsVisible(!binding.appbarTop.isVisible)
true
}
else -> super.onKeyDown(keyCode, event)
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
return controlDelegate.onKeyDown(keyCode, event) || super.onKeyDown(keyCode, event)
}
override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean {
return (isVolumeKeysSwitchEnabled &&
(keyCode == KeyEvent.KEYCODE_VOLUME_DOWN || keyCode == KeyEvent.KEYCODE_VOLUME_UP))
|| super.onKeyUp(keyCode, event)
return controlDelegate.onKeyUp(keyCode, event) || super.onKeyUp(keyCode, event)
}
override fun onChapterChanged(chapter: MangaChapter) {
state = state.copy(
chapterId = chapter.id,
page = 0,
scroll = 0
)
reader?.updateState(chapterId = chapter.id)
viewModel.switchChapter(chapter.id)
}
override fun onPageSelected(page: MangaPage) {
reader?.updateState(pageId = page.id)
lifecycleScope.launch(Dispatchers.Default) {
val pages = viewModel.content.value?.pages ?: return@launch
val index = pages.indexOfFirst { it.id == page.id }
if (index != -1) {
withContext(Dispatchers.Main) {
reader?.switchPageTo(index, true)
}
}
}
}
override fun onReaderModeChanged(mode: ReaderMode) {
//TODO save state
viewModel.setMode(state.manga, mode)
viewModel.switchMode(mode)
}
private fun onPageSaved(uri: Uri?) {
@@ -363,22 +286,6 @@ class ReaderActivity : BaseFullscreenActivity<ActivityReaderBinding>(),
}
}
override fun onPageChanged(chapter: MangaChapter, page: Int) {
title = chapter.name
state.manga.chapters?.run {
supportActionBar?.subtitle =
getString(R.string.chapter_d_of_d, chapter.number, size)
}
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
when (key) {
AppSettings.KEY_READER_SWITCHERS -> loadSwitchSettings()
AppSettings.KEY_READER_ANIMATION,
AppSettings.KEY_ZOOM_MODE -> reader?.recreateAdapter()
}
}
private fun showWaitWhileLoading() {
Toast.makeText(this, R.string.wait_for_loading_finish, Toast.LENGTH_SHORT).apply {
setGravity(Gravity.CENTER, 0, 0)
@@ -416,10 +323,20 @@ class ReaderActivity : BaseFullscreenActivity<ActivityReaderBinding>(),
.build()
}
private fun loadSwitchSettings() {
settings.readerPageSwitch.let {
isTapSwitchEnabled = it.contains(AppSettings.PAGE_SWITCH_TAPS)
isVolumeKeysSwitchEnabled = it.contains(AppSettings.PAGE_SWITCH_VOLUME_KEYS)
override fun switchPageBy(delta: Int) {
reader?.switchPageBy(delta)
}
override fun toggleUiVisibility() {
setUiIsVisible(!binding.appbarTop.isVisible)
}
private fun onUiStateChanged(uiState: ReaderUiState) {
title = uiState.chapterName ?: uiState.mangaName ?: getString(R.string.loading_)
supportActionBar?.subtitle = if (uiState.chapterNumber in 1..uiState.chaptersTotal) {
getString(R.string.chapter_d_of_d, uiState.chapterNumber, uiState.chaptersTotal)
} else {
null
}
}
@@ -427,32 +344,16 @@ class ReaderActivity : BaseFullscreenActivity<ActivityReaderBinding>(),
private const val EXTRA_STATE = "state"
fun newIntent(context: Context, state: ReaderState) =
Intent(context, ReaderActivity::class.java)
fun newIntent(context: Context, manga: Manga, state: ReaderState?): Intent {
return Intent(context, ReaderActivity::class.java)
.putExtra(MangaIntent.KEY_MANGA, manga)
.putExtra(EXTRA_STATE, state)
}
fun newIntent(context: Context, manga: Manga, chapterId: Long = -1) = newIntent(
context, ReaderState(
manga = manga,
chapterId = if (chapterId == -1L) manga.chapters?.firstOrNull()?.id
?: -1 else chapterId,
page = 0,
scroll = 0
)
)
fun newIntent(context: Context, manga: Manga, history: MangaHistory?) =
if (history == null) {
newIntent(context, manga)
} else {
newIntent(
context, ReaderState(
manga = manga,
chapterId = history.chapterId,
page = history.page,
scroll = history.scroll
)
)
}
fun newIntent(context: Context, mangaId: Long, state: ReaderState?): Intent {
return Intent(context, ReaderActivity::class.java)
.putExtra(MangaIntent.KEY_ID, mangaId)
.putExtra(EXTRA_STATE, state)
}
}
}

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.reader.ui
import android.content.DialogInterface
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
@@ -31,6 +32,7 @@ class ReaderConfigDialog : AlertDialogFragment<DialogReaderConfigBinding>(),
override fun onBuildDialog(builder: AlertDialog.Builder) {
builder.setTitle(R.string.read_mode)
.setPositiveButton(R.string.done, null)
.setCancelable(true)
}
@@ -40,19 +42,19 @@ class ReaderConfigDialog : AlertDialogFragment<DialogReaderConfigBinding>(),
binding.buttonReversed.isChecked = mode == ReaderMode.REVERSED
binding.buttonWebtoon.isChecked = mode == ReaderMode.WEBTOON
binding.buttonOk.setOnClickListener(this)
binding.buttonStandard.setOnClickListener(this)
binding.buttonReversed.setOnClickListener(this)
binding.buttonWebtoon.setOnClickListener(this)
}
override fun onDismiss(dialog: DialogInterface) {
((parentFragment as? Callback)
?: (activity as? Callback))?.onReaderModeChanged(mode)
super.onDismiss(dialog)
}
override fun onClick(v: View) {
when (v.id) {
R.id.button_ok -> {
((parentFragment as? Callback)
?: (activity as? Callback))?.onReaderModeChanged(mode)
dismiss()
}
R.id.button_standard -> mode = ReaderMode.STANDARD
R.id.button_webtoon -> mode = ReaderMode.WEBTOON
R.id.button_reversed -> mode = ReaderMode.REVERSED

View File

@@ -0,0 +1,8 @@
package org.koitharu.kotatsu.reader.ui
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
data class ReaderContent(
val pages: List<ReaderPage>,
val state: ReaderState?
)

View File

@@ -1,14 +0,0 @@
package org.koitharu.kotatsu.reader.ui
import org.koitharu.kotatsu.core.model.MangaChapter
interface ReaderListener {
fun onPageChanged(chapter: MangaChapter, page: Int)
fun saveState(chapterId: Long, page: Int, scroll: Int)
fun onLoadingStateChanged(isLoading: Boolean)
fun onError(error: Throwable)
}

View File

@@ -1,21 +1,29 @@
package org.koitharu.kotatsu.reader.ui
import android.os.Parcelable
import kotlinx.android.parcel.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.core.model.MangaHistory
@Parcelize
data class ReaderState(
val manga: Manga,
val chapterId: Long,
val page: Int,
val scroll: Int
) : Parcelable {
@IgnoredOnParcel
val chapter: MangaChapter? by lazy {
manga.chapters?.find { it.id == chapterId }
companion object {
fun from(history: MangaHistory) = ReaderState(
chapterId = history.chapterId,
page = history.page,
scroll = history.scroll
)
fun initial(manga: Manga) = ReaderState(
chapterId = manga.chapters?.firstOrNull()?.id ?: error("Cannot find first chapter"),
page = 0,
scroll = 0
)
}
}

View File

@@ -2,96 +2,198 @@ package org.koitharu.kotatsu.reader.ui
import android.content.ContentResolver
import android.net.Uri
import android.util.LongSparseArray
import android.webkit.URLUtil
import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import okhttp3.OkHttpClient
import okhttp3.Request
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.base.domain.MangaUtils
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.exceptions.MangaNotFoundException
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.core.model.MangaPage
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
import org.koitharu.kotatsu.utils.MediaStoreCompat
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.*
class ReaderViewModel(
intent: MangaIntent,
state: ReaderState?,
private val dataRepository: MangaDataRepository,
private val historyRepository: HistoryRepository,
private val settings: AppSettings
) : BaseViewModel() {
val reader = MutableLiveData<Pair<Manga, ReaderMode>>()
val onPageSaved = SingleLiveEvent<Uri?>()
private var loadingJob: Job? = null
private val currentState = MutableStateFlow(state)
private val mangaData = MutableStateFlow<Manga?>(intent.manga)
private val chapters = LongSparseArray<MangaChapter>()
fun init(manga: Manga) {
launchLoadingJob {
val mode = withContext(Dispatchers.Default) {
val repo = manga.source.repository
val chapter =
(manga.chapters ?: throw RuntimeException("Chapters is null")).random()
var mode = dataRepository.getReaderMode(manga.id)
if (mode == null) {
val pages = repo.getPages(chapter)
val isWebtoon = MangaUtils.determineMangaIsWebtoon(pages)
mode = getReaderMode(isWebtoon)
if (isWebtoon != null) {
dataRepository.savePreferences(
manga = manga,
mode = mode
)
}
}
mode
val readerMode = MutableLiveData<ReaderMode>()
val onPageSaved = SingleLiveEvent<Uri?>()
val uiState = 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()
)
}.flowOn(Dispatchers.Default).asLiveData(viewModelScope.coroutineContext)
val content = MutableLiveData<ReaderContent>(ReaderContent(emptyList(), null))
val manga: Manga?
get() = mangaData.value
val readerAnimation = settings.observe()
.filter { it == AppSettings.KEY_READER_ANIMATION }
.map { settings.readerAnimation }
.onStart { emit(settings.readerAnimation) }
.distinctUntilChanged()
.flowOn(Dispatchers.IO)
.asLiveData(viewModelScope.coroutineContext)
val onZoomChanged = settings.observe()
.filter { it == AppSettings.KEY_ZOOM_MODE }
.flowOn(Dispatchers.IO)
.asLiveEvent(viewModelScope.coroutineContext)
init {
loadingJob = launchLoadingJob(Dispatchers.Default) {
var manga = dataRepository.resolveIntent(intent)
?: throw MangaNotFoundException("Cannot find manga")
mangaData.value = manga
val repo = manga.source.repository
manga = repo.getDetails(manga)
manga.chapters?.forEach {
chapters.put(it.id, it)
}
reader.value = manga to mode
mangaData.value = manga
// determine mode
val mode =
dataRepository.getReaderMode(manga.id) ?: manga.chapters?.randomOrNull()?.let {
val pages = repo.getPages(it)
val isWebtoon = MangaUtils.determineMangaIsWebtoon(pages)
val newMode = getReaderMode(isWebtoon)
if (isWebtoon != null) {
dataRepository.savePreferences(manga, newMode)
}
newMode
} ?: error("There are no chapters in this manga")
// obtain state
if (state == null) {
currentState.value = historyRepository.getOne(manga)?.let {
ReaderState.from(it)
} ?: ReaderState.initial(manga)
}
readerMode.postValue(mode)
val pages = loadChapter(checkNotNull(manga.chapters?.firstOrNull()).id)
content.postValue(ReaderContent(pages, currentState.value))
}
}
fun setMode(manga: Manga, mode: ReaderMode) {
fun switchMode(newMode: ReaderMode) {
launchJob {
val manga = checkNotNull(mangaData.value)
dataRepository.savePreferences(
manga = manga,
mode = mode
mode = newMode
)
reader.value = manga to mode
readerMode.value = newMode
}
}
fun savePage(resolver: ContentResolver, page: MangaPage) {
launchJob {
withContext(Dispatchers.Default) {
try {
val repo = page.source.repository
val url = repo.getPageFullUrl(page)
val request = Request.Builder()
.url(url)
.get()
.build()
val uri = get<OkHttpClient>().newCall(request).await().use { response ->
val fileName =
URLUtil.guessFileName(
url,
response.contentDisposition,
response.mimeType
)
MediaStoreCompat.insertImage(resolver, fileName) {
response.body!!.byteStream().copyTo(it)
}
fun saveCurrentState(state: ReaderState? = null) {
saveState(
mangaData.value ?: return,
state ?: currentState.value ?: return
)
}
fun getCurrentState() = currentState.value
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() }
}
fun saveCurrentPage(resolver: ContentResolver) {
launchJob(Dispatchers.Default) {
try {
val page =
content.value?.pages?.randomOrNull()?.toMangaPage() ?: return@launchJob //TODO
val repo = page.source.repository
val url = repo.getPageFullUrl(page)
val request = Request.Builder()
.url(url)
.get()
.build()
val uri = get<OkHttpClient>().newCall(request).await().use { response ->
val fileName =
URLUtil.guessFileName(
url,
response.contentDisposition,
response.mimeType
)
MediaStoreCompat.insertImage(resolver, fileName) {
checkNotNull(response.body).byteStream().copyTo(it)
}
onPageSaved.postCall(uri)
} catch (e: CancellationException) {
} catch (e: Exception) {
onPageSaved.postCall(null)
}
onPageSaved.postCall(uri)
} catch (e: CancellationException) {
} catch (e: Exception) {
onPageSaved.postCall(null)
}
}
}
fun switchChapter(id: Long) {
val prevJob = loadingJob
loadingJob = launchLoadingJob(Dispatchers.Default) {
prevJob?.cancelAndJoin()
content.postValue(ReaderContent(emptyList(), null))
val newPages = loadChapter(id)
content.postValue(ReaderContent(newPages, ReaderState(id, 0, 0)))
}
}
fun onCurrentPageChanged(position: Int) {
val pages = content.value?.pages ?: return
pages.getOrNull(position)?.let {
val currentValue = currentState.value
if (currentValue != null && currentValue.chapterId != it.chapterId) {
currentState.value = currentValue.copy(chapterId = it.chapterId)
}
}
when {
loadingJob?.isActive == true -> return
pages.isEmpty() -> return
position <= BOUNDS_PAGE_OFFSET -> {
val chapterId = pages.first().chapterId
loadPrevNextChapter(chapterId, -1)
}
position >= pages.size - BOUNDS_PAGE_OFFSET -> {
val chapterId = pages.last().chapterId
loadPrevNextChapter(chapterId, 1)
}
}
}
@@ -102,12 +204,56 @@ class ReaderViewModel(
else -> ReaderMode.STANDARD
}
companion object : KoinComponent {
private suspend fun loadChapter(chapterId: Long): List<ReaderPage> {
val manga = checkNotNull(mangaData.value) { "Manga is null" }
val chapter = checkNotNull(chapters.get(chapterId)) { "Chapter $chapterId not found" }
val repo = manga.source.repository
return repo.getPages(chapter).mapIndexed { index, page ->
ReaderPage.from(page, index, chapterId)
}
}
fun saveState(state: ReaderState) {
private fun loadPrevNextChapter(currentId: Long, delta: Int) {
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))
}
}
private companion object : KoinComponent {
const val BOUNDS_PAGE_OFFSET = 2
const val PAGES_TRIM_THRESHOLD = 120
fun saveState(manga: Manga, state: ReaderState) {
processLifecycleScope.launch(Dispatchers.Default + IgnoreErrors) {
get<HistoryRepository>().addOrUpdate(
manga = state.manga,
manga = manga,
chapterId = state.chapterId,
page = state.page,
scroll = state.scroll

View File

@@ -1,267 +0,0 @@
package org.koitharu.kotatsu.reader.ui.base
import android.content.Context
import android.os.Bundle
import android.view.View
import androidx.annotation.CallSuper
import androidx.collection.LongSparseArray
import androidx.core.view.postDelayed
import androidx.viewbinding.ViewBinding
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.core.model.MangaPage
import org.koitharu.kotatsu.reader.ui.PageLoader
import org.koitharu.kotatsu.reader.ui.ReaderListener
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.utils.ext.associateByLong
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
abstract class AbstractReader<B : ViewBinding> : BaseFragment<B>(), OnBoundsScrollListener {
protected lateinit var manga: Manga
private set
private lateinit var chapters: LongSparseArray<MangaChapter>
protected val loader by lazy(LazyThreadSafetyMode.NONE) {
PageLoader()
}
protected val pages = ArrayDeque<ReaderPage>()
protected var readerAdapter: BaseReaderAdapter? = null
private set
val itemsCount: Int
get() = readerAdapter?.itemCount ?: 0
val hasItems: Boolean
get() = itemsCount != 0
val currentPage: MangaPage?
get() = pages.getOrNull(getCurrentItem())?.toMangaPage()
private var readerListener: ReaderListener? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
manga = requireNotNull(requireArguments().getParcelable<ReaderState>(ARG_STATE)).manga
chapters = requireNotNull(manga.chapters).associateByLong { it.id }
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
readerAdapter = onCreateAdapter(pages)
@Suppress("RemoveExplicitTypeArguments")
val state = savedInstanceState?.getParcelable<ReaderState>(ARG_STATE)
?: requireArguments().getParcelable<ReaderState>(ARG_STATE)!!
loadChapter(state.chapterId) {
pages.clear()
it.mapIndexedTo(pages) { i, p ->
ReaderPage.from(p, i, state.chapterId)
}
readerAdapter?.notifyDataSetChanged()
setCurrentItem(state.page, false)
if (state.scroll != 0) {
restorePageScroll(state.page, state.scroll)
}
}
}
override fun onAttach(context: Context) {
super.onAttach(context)
readerListener = activity as? ReaderListener
}
override fun onDetach() {
readerListener = null
super.onDetach()
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
val page = pages.getOrNull(getCurrentItem()) ?: return
outState.putParcelable(
ARG_STATE, ReaderState(
manga = manga,
chapterId = page.chapterId,
page = page.index,
scroll = getCurrentPageScroll()
)
)
}
override fun onScrolledToStart() {
val chapterId = getFirstPage()?.chapterId ?: return
val index = manga.chapters?.indexOfFirst { it.id == chapterId } ?: return
val prevChapterId = manga.chapters!!.getOrNull(index - 1)?.id ?: return
loadChapter(prevChapterId) {
pages.addAll(0, it.mapIndexed { i, p ->
ReaderPage.from(p, i, prevChapterId)
})
readerAdapter?.notifyItemsPrepended(it.size)
view?.postDelayed(500) {
trimEnd()
}
}
}
override fun onScrolledToEnd() {
val chapterId = getLastPage()?.chapterId ?: return
val index = manga.chapters?.indexOfLast { it.id == chapterId } ?: return
val nextChapterId = manga.chapters!!.getOrNull(index + 1)?.id ?: return
loadChapter(nextChapterId) {
pages.addAll(it.mapIndexed { i, p ->
ReaderPage.from(p, i, nextChapterId)
})
readerAdapter?.notifyItemsAppended(it.size)
view?.postDelayed(500) {
trimStart()
}
}
}
override fun onDestroyView() {
readerAdapter = null
super.onDestroyView()
}
override fun onDestroy() {
loader.dispose()
super.onDestroy()
}
@CallSuper
open fun recreateAdapter() {
readerAdapter = onCreateAdapter(pages)
}
fun getPages(): List<MangaPage>? {
val chapterId = (pages.getOrNull(getCurrentItem()) ?: return null).chapterId
// TODO optimize
return pages.filter { it.chapterId == chapterId }.map { it.toMangaPage() }
}
override fun onPause() {
saveState()
super.onPause()
}
private fun loadChapter(chapterId: Long, callback: suspend (List<MangaPage>) -> Unit) {
viewLifecycleScope.launch {
readerListener?.onLoadingStateChanged(isLoading = true)
try {
val pages = withContext(Dispatchers.Default) {
val chapter = chapters.get(chapterId)
?: throw RuntimeException("Chapter $chapterId not found")
val repo = manga.source.repository
repo.getPages(chapter)
}
callback(pages)
} catch (_: CancellationException) {
} catch (e: Throwable) {
readerListener?.onError(e)
} finally {
readerListener?.onLoadingStateChanged(isLoading = false)
}
}
}
private fun trimStart() {
/*var removed = 0
while (pages.groupCount > 3 && pages.size > 8) {
removed += pages.removeFirst().size
}
if (removed != 0) {
adapter?.notifyItemsRemovedStart(removed)
Log.i(TAG, "Removed $removed pages from start")
}*/
}
private fun trimEnd() {
/*var removed = 0
while (pages.groupCount > 3 && pages.size > 8) {
removed += pages.removeLast().size
}
if (removed != 0) {
adapter?.notifyItemsRemovedEnd(removed)
Log.i(TAG, "Removed $removed pages from end")
}*/
}
protected fun notifyPageChanged(position: Int) {
val page = pages.getOrNull(position) ?: return
val chapter = chapters.get(page.chapterId) ?: return
readerListener?.onPageChanged(
chapter = chapter,
page = page.index
)
}
private fun saveState() {
val page = pages.getOrNull(getCurrentItem()) ?: return
readerListener?.saveState(page.chapterId, page.index, getCurrentPageScroll())
}
open fun switchPageBy(delta: Int) {
setCurrentItem(getCurrentItem() + delta, true)
}
fun updateState(chapterId: Long = 0, pageId: Long = 0) {
val currentChapterId = pages.getOrNull(getCurrentItem())?.chapterId ?: 0L
if (chapterId != 0L && chapterId != currentChapterId) {
pages.clear()
readerAdapter?.notifyDataSetChanged()
loadChapter(chapterId) {
pages.clear()
it.mapIndexedTo(pages) { i, p ->
ReaderPage.from(p, i, chapterId)
}
readerAdapter?.notifyDataSetChanged()
setCurrentItem(
if (pageId == 0L) {
0
} else {
it.indexOfFirst { x -> x.id == pageId }.coerceAtLeast(0)
}, false
)
}
} else {
var index = 0
if (pageId != 0L) {
index = pages.indexOfFirst {
it.chapterId == currentChapterId && it.id == pageId
}
if (index == -1) { // try to find chapter at least
index = pages.indexOfFirst {
it.chapterId == currentChapterId
}
}
if (index == -1) {
index = 0
}
}
setCurrentItem(index, false)
}
}
protected open fun getLastPage() = pages.lastOrNull()
protected open fun getFirstPage() = pages.firstOrNull()
protected abstract fun getCurrentItem(): Int
protected abstract fun getCurrentPageScroll(): Int
protected abstract fun restorePageScroll(position: Int, scroll: Int)
protected abstract fun setCurrentItem(position: Int, isSmooth: Boolean)
protected abstract fun onCreateAdapter(dataSet: List<ReaderPage>): BaseReaderAdapter
protected companion object {
const val ARG_STATE = "state"
}
}

View File

@@ -1,52 +0,0 @@
package org.koitharu.kotatsu.reader.ui.base
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.base.ui.list.BaseViewHolder
abstract class BaseReaderAdapter(protected val pages: List<ReaderPage>) :
RecyclerView.Adapter<BaseViewHolder<ReaderPage, Unit, *>>() {
init {
@Suppress("LeakingThis")
setHasStableIds(true)
}
override fun onBindViewHolder(holder: BaseViewHolder<ReaderPage, Unit, *>, position: Int) {
val item = pages[position]
holder.bind(item, Unit)
}
open fun getItem(position: Int) = pages[position]
open fun notifyItemsAppended(count: Int) {
notifyItemRangeInserted(pages.size - count, count)
}
open fun notifyItemsPrepended(count: Int) {
notifyItemRangeInserted(0, count)
}
open fun notifyItemsRemovedStart(count: Int) {
notifyItemRangeRemoved(0, count)
}
open fun notifyItemsRemovedEnd(count: Int) {
notifyItemRangeRemoved(pages.size - count, count)
}
override fun getItemId(position: Int) = pages[position].id
final override fun getItemCount() = pages.size
final override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): BaseViewHolder<ReaderPage, Unit, *> {
return onCreateViewHolder(parent).also(this::onViewHolderCreated)
}
protected open fun onViewHolderCreated(holder: BaseViewHolder<ReaderPage, Unit, *>) = Unit
protected abstract fun onCreateViewHolder(parent: ViewGroup): BaseViewHolder<ReaderPage, Unit, *>
}

View File

@@ -0,0 +1,35 @@
package org.koitharu.kotatsu.reader.ui.pager
import android.content.Context
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.reader.ui.PageLoader
abstract class BasePageHolder<B : ViewBinding>(
protected val binding: B,
loader: PageLoader,
settings: AppSettings
) : RecyclerView.ViewHolder(binding.root), PageHolderDelegate.Callback {
protected val delegate = PageHolderDelegate(loader, settings, this)
val context: Context
get() = itemView.context
var boundData: ReaderPage? = null
private set
fun requireData(): ReaderPage {
return checkNotNull(boundData) { "Calling requireData() before bind()" }
}
fun bind(data: ReaderPage) {
boundData = data
onBind(data)
}
protected abstract fun onBind(data: ReaderPage)
protected open fun onRecycled() = Unit
}

View File

@@ -0,0 +1,57 @@
package org.koitharu.kotatsu.reader.ui.pager
import android.os.Bundle
import android.view.View
import androidx.lifecycle.lifecycleScope
import androidx.viewbinding.ViewBinding
import org.koin.android.ext.android.get
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.reader.ui.PageLoader
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.ReaderViewModel
abstract class BaseReader<B : ViewBinding> : BaseFragment<B>() {
protected val viewModel by sharedViewModel<ReaderViewModel>()
protected val loader by lazy(LazyThreadSafetyMode.NONE) {
PageLoader(lifecycleScope, get(), get())
}
private var lastReaderState: ReaderState? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
lastReaderState = savedInstanceState?.getParcelable(KEY_STATE) ?: lastReaderState
viewModel.content.observe(viewLifecycleOwner) {
onPagesChanged(it.pages, lastReaderState ?: it.state)
lastReaderState = null
}
}
override fun onDestroyView() {
lastReaderState = getCurrentState()
super.onDestroyView()
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
getCurrentState()?.let {
lastReaderState = it
}
outState.putParcelable(KEY_STATE, lastReaderState)
}
abstract fun switchPageBy(delta: Int)
abstract fun switchPageTo(position: Int, smooth: Boolean)
abstract fun getCurrentState(): ReaderState?
protected abstract fun onPagesChanged(pages: List<ReaderPage>, pendingState: ReaderState?)
private companion object {
const val KEY_STATE = "state"
}
}

View File

@@ -0,0 +1,69 @@
package org.koitharu.kotatsu.reader.ui.pager
import android.view.ViewGroup
import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.reader.ui.PageLoader
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
abstract class BaseReaderAdapter<H : BasePageHolder<*>>(
private val loader: PageLoader,
private val settings: AppSettings
) : RecyclerView.Adapter<H>() {
private val differ = AsyncListDiffer(this, DiffCallback())
init {
setHasStableIds(true)
}
override fun onBindViewHolder(holder: H, position: Int) {
holder.bind(differ.currentList[position])
}
open fun getItem(position: Int): ReaderPage = differ.currentList[position]
open fun getItemOrNull(position: Int) = differ.currentList.getOrNull(position)
override fun getItemId(position: Int) = differ.currentList[position].id
final override fun getItemCount() = differ.currentList.size
final override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): H = onCreateViewHolder(parent, loader, settings).also(this::onViewHolderCreated)
fun setItems(items: List<ReaderPage>, callback: Runnable) {
differ.submitList(items, callback)
}
suspend fun setItems(items: List<ReaderPage>) = suspendCoroutine<Unit> { cont ->
differ.submitList(items) {
cont.resume(Unit)
}
}
protected open fun onViewHolderCreated(holder: H) = Unit
protected abstract fun onCreateViewHolder(
parent: ViewGroup,
loader: PageLoader,
settings: AppSettings
): H
private class DiffCallback : DiffUtil.ItemCallback<ReaderPage>() {
override fun areItemsTheSame(oldItem: ReaderPage, newItem: ReaderPage): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: ReaderPage, newItem: ReaderPage): Boolean {
return oldItem == newItem
}
}
}

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.reader.ui.base
package org.koitharu.kotatsu.reader.ui.pager
interface OnBoundsScrollListener {

View File

@@ -1,10 +1,9 @@
package org.koitharu.kotatsu.reader.ui.base
package org.koitharu.kotatsu.reader.ui.pager
import android.net.Uri
import androidx.core.net.toUri
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import kotlinx.coroutines.*
import org.koin.core.component.inject
import org.koitharu.kotatsu.core.model.MangaPage
import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.prefs.AppSettings
@@ -16,11 +15,11 @@ import java.io.IOException
class PageHolderDelegate(
private val loader: PageLoader,
private val settings: AppSettings,
private val callback: Callback
) : SubsamplingScaleImageView.DefaultOnImageEventListener(),
CoroutineScope by loader {
private val settings by loader.inject<AppSettings>()
private var state = State.EMPTY
private var job: Job? = null
private var file: File? = null

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.reader.ui.base
package org.koitharu.kotatsu.reader.ui.pager
import android.os.Parcelable
import kotlinx.parcelize.Parcelize

View File

@@ -0,0 +1,8 @@
package org.koitharu.kotatsu.reader.ui.pager
data class ReaderUiState(
val mangaName: String?,
val chapterName: String?,
val chapterNumber: Int,
val chaptersTotal: Int
)

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.reader.ui.reversed
package org.koitharu.kotatsu.reader.ui.pager.reversed
import android.view.View
import androidx.viewpager2.widget.ViewPager2

View File

@@ -1,13 +1,18 @@
package org.koitharu.kotatsu.reader.ui.reversed
package org.koitharu.kotatsu.reader.ui.pager.reversed
import android.graphics.PointF
import android.view.ViewGroup
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.databinding.ItemPageBinding
import org.koitharu.kotatsu.reader.ui.PageLoader
import org.koitharu.kotatsu.reader.ui.standard.PageHolder
import org.koitharu.kotatsu.reader.ui.pager.standard.PageHolder
class ReversedPageHolder(parent: ViewGroup, loader: PageLoader) : PageHolder(parent, loader) {
class ReversedPageHolder(
binding: ItemPageBinding,
loader: PageLoader,
settings: AppSettings
) : PageHolder(binding, loader, settings) {
override fun onImageShowing(zoom: ZoomMode) {
with(binding.ssiv) {

View File

@@ -0,0 +1,24 @@
package org.koitharu.kotatsu.reader.ui.pager.reversed
import android.view.LayoutInflater
import android.view.ViewGroup
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.databinding.ItemPageBinding
import org.koitharu.kotatsu.reader.ui.PageLoader
import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
class ReversedPagesAdapter(
loader: PageLoader,
settings: AppSettings
) : BaseReaderAdapter<ReversedPageHolder>(loader, settings) {
override fun onCreateViewHolder(
parent: ViewGroup,
loader: PageLoader,
settings: AppSettings
) = ReversedPageHolder(
binding = ItemPageBinding.inflate(LayoutInflater.from(parent.context), parent, false),
loader = loader,
settings = settings
)
}

View File

@@ -0,0 +1,78 @@
package org.koitharu.kotatsu.reader.ui.pager.reversed
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import org.koin.android.ext.android.get
import org.koitharu.kotatsu.databinding.FragmentReaderStandardBinding
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.pager.BaseReader
import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.utils.ext.doOnPageChanged
class ReversedReaderFragment : BaseReader<FragmentReaderStandardBinding>() {
private var pagerAdapter: ReversedPagesAdapter? = null
override fun onInflateView(
inflater: LayoutInflater,
container: ViewGroup?
) = FragmentReaderStandardBinding.inflate(inflater, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
pagerAdapter = ReversedPagesAdapter(loader, get())
with(binding.pager) {
adapter = pagerAdapter
offscreenPageLimit = 2
doOnPageChanged(::notifyPageChanged)
}
}
override fun onDestroyView() {
pagerAdapter = null
super.onDestroyView()
}
override fun switchPageBy(delta: Int) {
with(binding.pager) {
setCurrentItem(currentItem - delta, true)
}
}
override fun switchPageTo(position: Int, smooth: Boolean) {
binding.pager.setCurrentItem(reversed(position), smooth)
}
override fun onPagesChanged(pages: List<ReaderPage>, pendingState: ReaderState?) {
pagerAdapter?.setItems(pages.asReversed()) {
if (pendingState != null) {
val position = pages.indexOfFirst {
it.chapterId == pendingState.chapterId && it.index == pendingState.page
}
if (position == -1) return@setItems
binding.pager.setCurrentItem(position, false)
}
}
}
override fun getCurrentState(): ReaderState? = bindingOrNull()?.run {
val adapter = pager.adapter as? BaseReaderAdapter<*>
val page = adapter?.getItemOrNull(pager.currentItem) ?: return@run null
ReaderState(
chapterId = page.chapterId,
page = page.index,
scroll = 0
)
}
private fun notifyPageChanged(page: Int) {
viewModel.onCurrentPageChanged(reversed(page))
}
private fun reversed(position: Int): Int {
return ((pagerAdapter?.itemCount ?: 0) - position - 1).coerceAtLeast(0)
}
}

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.reader.ui.standard
package org.koitharu.kotatsu.reader.ui.pager.standard
import android.view.View
import androidx.viewpager2.widget.ViewPager2

View File

@@ -1,35 +1,32 @@
package org.koitharu.kotatsu.reader.ui.standard
package org.koitharu.kotatsu.reader.ui.pager.standard
import android.graphics.PointF
import android.net.Uri
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import com.davemorrissey.labs.subscaleview.ImageSource
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.BaseViewHolder
import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.databinding.ItemPageBinding
import org.koitharu.kotatsu.reader.ui.PageLoader
import org.koitharu.kotatsu.reader.ui.base.PageHolderDelegate
import org.koitharu.kotatsu.reader.ui.base.ReaderPage
import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
open class PageHolder(parent: ViewGroup, loader: PageLoader) :
BaseViewHolder<ReaderPage, Unit, ItemPageBinding>(
ItemPageBinding.inflate(LayoutInflater.from(parent.context), parent, false)
), PageHolderDelegate.Callback, View.OnClickListener {
private val delegate = PageHolderDelegate(loader, this)
open class PageHolder(
binding: ItemPageBinding,
loader: PageLoader,
settings: AppSettings
) : BasePageHolder<ItemPageBinding>(binding, loader, settings), View.OnClickListener {
init {
binding.ssiv.setOnImageEventListener(delegate)
binding.buttonRetry.setOnClickListener(this)
}
override fun onBind(data: ReaderPage, extra: Unit) {
override fun onBind(data: ReaderPage) {
delegate.onBind(data.toMangaPage())
}

View File

@@ -1,8 +1,8 @@
package org.koitharu.kotatsu.reader.ui.standard
package org.koitharu.kotatsu.reader.ui.pager.standard
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
import org.koitharu.kotatsu.reader.ui.base.OnBoundsScrollListener
import org.koitharu.kotatsu.reader.ui.pager.OnBoundsScrollListener
class PagerPaginationListener(
private val adapter: RecyclerView.Adapter<*>,

View File

@@ -0,0 +1,93 @@
package org.koitharu.kotatsu.reader.ui.pager.standard
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import kotlinx.coroutines.async
import org.koin.android.ext.android.get
import org.koitharu.kotatsu.databinding.FragmentReaderStandardBinding
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.pager.BaseReader
import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.utils.ext.doOnPageChanged
import org.koitharu.kotatsu.utils.ext.swapAdapter
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
class PagerReaderFragment : BaseReader<FragmentReaderStandardBinding>() {
private var pagesAdapter: PagesAdapter? = null
override fun onInflateView(
inflater: LayoutInflater,
container: ViewGroup?
) = FragmentReaderStandardBinding.inflate(inflater, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
pagesAdapter = PagesAdapter(loader, get())
with(binding.pager) {
adapter = pagesAdapter
offscreenPageLimit = 2
doOnPageChanged(::notifyPageChanged)
}
viewModel.readerAnimation.observe(viewLifecycleOwner) {
val transformer = if (it) PageAnimTransformer() else null
binding.pager.setPageTransformer(transformer)
}
viewModel.onZoomChanged.observe(viewLifecycleOwner) {
pagesAdapter = PagesAdapter(loader, get())
binding.pager.swapAdapter(pagesAdapter)
}
}
override fun onDestroyView() {
pagesAdapter = null
super.onDestroyView()
}
override fun onPagesChanged(pages: List<ReaderPage>, pendingState: ReaderState?) {
viewLifecycleScope.launchWhenCreated {
val items = async {
pagesAdapter?.setItems(pages)
}
if (pendingState != null) {
val position = pages.indexOfFirst {
it.chapterId == pendingState.chapterId && it.index == pendingState.page
}
items.await() ?: return@launchWhenCreated
if (position != -1) {
binding.pager.setCurrentItem(position, false)
}
} else {
items.await()
}
}
}
override fun switchPageBy(delta: Int) {
with(binding.pager) {
setCurrentItem(currentItem + delta, true)
}
}
override fun switchPageTo(position: Int, smooth: Boolean) {
binding.pager.setCurrentItem(position, smooth)
}
override fun getCurrentState(): ReaderState? = bindingOrNull()?.run {
val adapter = pager.adapter as? BaseReaderAdapter<*>
val page = adapter?.getItemOrNull(pager.currentItem) ?: return@run null
ReaderState(
chapterId = page.chapterId,
page = page.index,
scroll = 0
)
}
private fun notifyPageChanged(page: Int) {
viewModel.onCurrentPageChanged(page)
}
}

View File

@@ -0,0 +1,24 @@
package org.koitharu.kotatsu.reader.ui.pager.standard
import android.view.LayoutInflater
import android.view.ViewGroup
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.databinding.ItemPageBinding
import org.koitharu.kotatsu.reader.ui.PageLoader
import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
class PagesAdapter(
loader: PageLoader,
settings: AppSettings
) : BaseReaderAdapter<PageHolder>(loader, settings) {
override fun onCreateViewHolder(
parent: ViewGroup,
loader: PageLoader,
settings: AppSettings
) = PageHolder(
binding = ItemPageBinding.inflate(LayoutInflater.from(parent.context), parent, false),
loader = loader,
settings = settings
)
}

View File

@@ -1,8 +1,8 @@
package org.koitharu.kotatsu.reader.ui.wetoon
package org.koitharu.kotatsu.reader.ui.pager.wetoon
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.reader.ui.base.OnBoundsScrollListener
import org.koitharu.kotatsu.reader.ui.pager.OnBoundsScrollListener
class ListPaginationListener(
private val offset: Int,

View File

@@ -0,0 +1,28 @@
package org.koitharu.kotatsu.reader.ui.pager.wetoon
import android.view.LayoutInflater
import android.view.ViewGroup
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.databinding.ItemPageWebtoonBinding
import org.koitharu.kotatsu.reader.ui.PageLoader
import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
class WebtoonAdapter(
loader: PageLoader,
settings: AppSettings
) : BaseReaderAdapter<WebtoonHolder>(loader, settings) {
override fun onCreateViewHolder(
parent: ViewGroup,
loader: PageLoader,
settings: AppSettings
) = WebtoonHolder(
binding = ItemPageWebtoonBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
),
loader = loader,
settings = settings
)
}

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.reader.ui.wetoon
package org.koitharu.kotatsu.reader.ui.pager.wetoon
import android.content.Context
import android.util.AttributeSet

View File

@@ -1,28 +1,26 @@
package org.koitharu.kotatsu.reader.ui.wetoon
package org.koitharu.kotatsu.reader.ui.pager.wetoon
import android.net.Uri
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import com.davemorrissey.labs.subscaleview.ImageSource
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.BaseViewHolder
import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.databinding.ItemPageWebtoonBinding
import org.koitharu.kotatsu.reader.ui.PageLoader
import org.koitharu.kotatsu.reader.ui.base.PageHolderDelegate
import org.koitharu.kotatsu.reader.ui.base.ReaderPage
import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
class WebtoonHolder(parent: ViewGroup, private val loader: PageLoader) :
BaseViewHolder<ReaderPage, Unit, ItemPageWebtoonBinding>(
ItemPageWebtoonBinding.inflate(LayoutInflater.from(parent.context), parent, false)
), PageHolderDelegate.Callback, View.OnClickListener {
class WebtoonHolder(
binding: ItemPageWebtoonBinding,
loader: PageLoader,
settings: AppSettings
) : BasePageHolder<ItemPageWebtoonBinding>(binding, loader, settings), View.OnClickListener {
private val delegate = PageHolderDelegate(loader, this)
private var scrollToRestore = 0
init {
@@ -30,7 +28,7 @@ class WebtoonHolder(parent: ViewGroup, private val loader: PageLoader) :
binding.buttonRetry.setOnClickListener(this)
}
override fun onBind(data: ReaderPage, extra: Unit) {
override fun onBind(data: ReaderPage) {
delegate.onBind(data.toMangaPage())
}

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.reader.ui.wetoon
package org.koitharu.kotatsu.reader.ui.pager.wetoon
import android.content.Context
import android.graphics.PointF

View File

@@ -0,0 +1,96 @@
package org.koitharu.kotatsu.reader.ui.pager.wetoon
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.animation.AccelerateDecelerateInterpolator
import kotlinx.coroutines.async
import org.koin.android.ext.android.get
import org.koitharu.kotatsu.databinding.FragmentReaderWebtoonBinding
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.pager.BaseReader
import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.utils.ext.doOnCurrentItemChanged
import org.koitharu.kotatsu.utils.ext.findCenterViewPosition
import org.koitharu.kotatsu.utils.ext.firstItem
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
class WebtoonReaderFragment : BaseReader<FragmentReaderWebtoonBinding>() {
private val scrollInterpolator = AccelerateDecelerateInterpolator()
private var webtoonAdapter: WebtoonAdapter? = null
override fun onInflateView(
inflater: LayoutInflater,
container: ViewGroup?
) = FragmentReaderWebtoonBinding.inflate(inflater, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
webtoonAdapter = WebtoonAdapter(loader, get())
with(binding.recyclerView) {
setHasFixedSize(true)
adapter = webtoonAdapter
doOnCurrentItemChanged(::notifyPageChanged)
}
}
override fun onDestroyView() {
webtoonAdapter = null
super.onDestroyView()
}
override fun onPagesChanged(pages: List<ReaderPage>, pendingState: ReaderState?) {
viewLifecycleScope.launchWhenCreated {
val setItems = async { webtoonAdapter?.setItems(pages) }
if (pendingState != null) {
val position = pages.indexOfFirst {
it.chapterId == pendingState.chapterId && it.index == pendingState.page
}
setItems.await() ?: return@launchWhenCreated
if (position != -1) {
binding.recyclerView.firstItem = position
// TODO check
(binding.recyclerView.findViewHolderForAdapterPosition(position) as? WebtoonHolder)
?.restoreScroll(pendingState.scroll)
}
} else {
setItems.await()
}
}
}
override fun getCurrentState(): ReaderState? = bindingOrNull()?.run {
val currentItem = recyclerView.findCenterViewPosition()
val adapter = recyclerView.adapter as? BaseReaderAdapter<*>
val page = adapter?.getItemOrNull(currentItem) ?: return@run null
ReaderState(
chapterId = page.chapterId,
page = page.index,
scroll = (recyclerView.findViewHolderForAdapterPosition(currentItem) as? WebtoonHolder)
?.getScrollY() ?: 0
)
}
private fun notifyPageChanged(page: Int) {
viewModel.onCurrentPageChanged(page)
}
override fun switchPageBy(delta: Int) {
binding.recyclerView.smoothScrollBy(
0,
(binding.recyclerView.height * 0.9).toInt() * delta,
scrollInterpolator
)
}
override fun switchPageTo(position: Int, smooth: Boolean) {
if (smooth) {
binding.recyclerView.smoothScrollToPosition(position)
} else {
binding.recyclerView.firstItem = position
}
}
}

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.reader.ui.wetoon
package org.koitharu.kotatsu.reader.ui.pager.wetoon
import android.content.Context
import android.util.AttributeSet

View File

@@ -1,45 +0,0 @@
package org.koitharu.kotatsu.reader.ui.reversed
import android.view.ViewGroup
import org.koitharu.kotatsu.base.ui.list.BaseViewHolder
import org.koitharu.kotatsu.reader.ui.PageLoader
import org.koitharu.kotatsu.reader.ui.base.BaseReaderAdapter
import org.koitharu.kotatsu.reader.ui.base.ReaderPage
class ReversedPagesAdapter(
pages: List<ReaderPage>,
private val loader: PageLoader
) : BaseReaderAdapter(pages) {
override fun onCreateViewHolder(parent: ViewGroup) = ReversedPageHolder(parent, loader)
override fun onBindViewHolder(holder: BaseViewHolder<ReaderPage, Unit, *>, position: Int) {
super.onBindViewHolder(holder, reversed(position))
}
override fun getItem(position: Int): ReaderPage {
return super.getItem(reversed(position))
}
override fun getItemId(position: Int): Long {
return super.getItemId(reversed(position))
}
override fun notifyItemsAppended(count: Int) {
super.notifyItemsPrepended(count)
}
override fun notifyItemsPrepended(count: Int) {
super.notifyItemsAppended(count)
}
override fun notifyItemsRemovedStart(count: Int) {
super.notifyItemsRemovedEnd(count)
}
override fun notifyItemsRemovedEnd(count: Int) {
super.notifyItemsRemovedStart(count)
}
private fun reversed(position: Int) = pages.size - position - 1
}

View File

@@ -1,107 +0,0 @@
package org.koitharu.kotatsu.reader.ui.reversed
import android.content.Context
import android.content.SharedPreferences
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import org.koin.android.ext.android.inject
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.databinding.FragmentReaderStandardBinding
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.base.AbstractReader
import org.koitharu.kotatsu.reader.ui.base.BaseReaderAdapter
import org.koitharu.kotatsu.reader.ui.base.ReaderPage
import org.koitharu.kotatsu.reader.ui.standard.PageAnimTransformer
import org.koitharu.kotatsu.reader.ui.standard.PagerPaginationListener
import org.koitharu.kotatsu.utils.ext.doOnPageChanged
import org.koitharu.kotatsu.utils.ext.swapAdapter
import org.koitharu.kotatsu.utils.ext.withArgs
class ReversedReaderFragment : AbstractReader<FragmentReaderStandardBinding>(),
SharedPreferences.OnSharedPreferenceChangeListener {
private var paginationListener: PagerPaginationListener? = null
private val settings by inject<AppSettings>()
override fun onInflateView(
inflater: LayoutInflater,
container: ViewGroup?
) = FragmentReaderStandardBinding.inflate(inflater, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
paginationListener = PagerPaginationListener(readerAdapter!!, 2, this)
with(binding.pager) {
adapter = readerAdapter
if (settings.readerAnimation) {
setPageTransformer(ReversedPageAnimTransformer())
}
offscreenPageLimit = 2
registerOnPageChangeCallback(paginationListener!!)
doOnPageChanged {
notifyPageChanged(reversed(it))
}
}
}
override fun onAttach(context: Context) {
super.onAttach(context)
settings.subscribe(this)
}
override fun onDetach() {
settings.unsubscribe(this)
super.onDetach()
}
override fun onDestroyView() {
paginationListener = null
super.onDestroyView()
}
override fun onCreateAdapter(dataSet: List<ReaderPage>): BaseReaderAdapter {
return ReversedPagesAdapter(dataSet, loader)
}
override fun recreateAdapter() {
super.recreateAdapter()
binding.pager.swapAdapter(readerAdapter)
}
override fun getCurrentItem() = reversed(binding.pager.currentItem)
override fun setCurrentItem(position: Int, isSmooth: Boolean) {
binding.pager.setCurrentItem(reversed(position), isSmooth)
}
override fun getCurrentPageScroll() = 0
override fun restorePageScroll(position: Int, scroll: Int) = Unit
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
when (key) {
AppSettings.KEY_READER_ANIMATION -> {
if (settings.readerAnimation) {
binding.pager.setPageTransformer(PageAnimTransformer())
} else {
binding.pager.setPageTransformer(null)
}
}
}
}
override fun getLastPage() = pages.firstOrNull()
override fun getFirstPage() = pages.lastOrNull()
private fun reversed(position: Int) = (itemsCount - position - 1).coerceAtLeast(0)
companion object {
fun newInstance(state: ReaderState) = ReversedReaderFragment().withArgs(1) {
putParcelable(ARG_STATE, state)
}
}
}

View File

@@ -1,97 +0,0 @@
package org.koitharu.kotatsu.reader.ui.standard
import android.content.Context
import android.content.SharedPreferences
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import org.koin.android.ext.android.inject
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.databinding.FragmentReaderStandardBinding
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.base.AbstractReader
import org.koitharu.kotatsu.reader.ui.base.BaseReaderAdapter
import org.koitharu.kotatsu.reader.ui.base.ReaderPage
import org.koitharu.kotatsu.utils.ext.doOnPageChanged
import org.koitharu.kotatsu.utils.ext.swapAdapter
import org.koitharu.kotatsu.utils.ext.withArgs
class PagerReaderFragment : AbstractReader<FragmentReaderStandardBinding>(),
SharedPreferences.OnSharedPreferenceChangeListener {
private var paginationListener: PagerPaginationListener? = null
private val settings by inject<AppSettings>()
override fun onInflateView(
inflater: LayoutInflater,
container: ViewGroup?
) = FragmentReaderStandardBinding.inflate(inflater, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
paginationListener = PagerPaginationListener(readerAdapter!!, 2, this)
with(binding.pager) {
adapter = readerAdapter
if (settings.readerAnimation) {
setPageTransformer(PageAnimTransformer())
}
offscreenPageLimit = 2
registerOnPageChangeCallback(paginationListener!!)
doOnPageChanged(::notifyPageChanged)
}
}
override fun onAttach(context: Context) {
super.onAttach(context)
settings.subscribe(this)
}
override fun onDetach() {
settings.unsubscribe(this)
super.onDetach()
}
override fun onDestroyView() {
paginationListener = null
super.onDestroyView()
}
override fun onCreateAdapter(dataSet: List<ReaderPage>): BaseReaderAdapter {
return PagesAdapter(dataSet, loader)
}
override fun recreateAdapter() {
super.recreateAdapter()
binding.pager.swapAdapter(readerAdapter)
}
override fun getCurrentItem() = binding.pager.currentItem
override fun setCurrentItem(position: Int, isSmooth: Boolean) {
binding.pager.setCurrentItem(position, isSmooth)
}
override fun getCurrentPageScroll() = 0
override fun restorePageScroll(position: Int, scroll: Int) = Unit
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
when (key) {
AppSettings.KEY_READER_ANIMATION -> {
if (settings.readerAnimation) {
binding.pager.setPageTransformer(PageAnimTransformer())
} else {
binding.pager.setPageTransformer(null)
}
}
}
}
companion object {
fun newInstance(state: ReaderState) = PagerReaderFragment().withArgs(1) {
putParcelable(ARG_STATE, state)
}
}
}

View File

@@ -1,14 +0,0 @@
package org.koitharu.kotatsu.reader.ui.standard
import android.view.ViewGroup
import org.koitharu.kotatsu.reader.ui.PageLoader
import org.koitharu.kotatsu.reader.ui.base.BaseReaderAdapter
import org.koitharu.kotatsu.reader.ui.base.ReaderPage
class PagesAdapter(
pages: List<ReaderPage>,
private val loader: PageLoader
) : BaseReaderAdapter(pages) {
override fun onCreateViewHolder(parent: ViewGroup) = PageHolder(parent, loader)
}

View File

@@ -0,0 +1,11 @@
package org.koitharu.kotatsu.reader.ui.thumbnails
import org.koitharu.kotatsu.core.model.MangaPage
import org.koitharu.kotatsu.core.parser.MangaRepository
data class PageThumbnail(
val number: Int,
val isCurrent: Boolean,
val repository: MangaRepository,
val page: MangaPage
)

View File

@@ -14,9 +14,10 @@ import org.koitharu.kotatsu.base.ui.BaseBottomSheet
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.core.model.MangaPage
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.databinding.SheetPagesBinding
import org.koitharu.kotatsu.list.ui.MangaListSpanResolver
import org.koitharu.kotatsu.reader.ui.thumbnails.adapter.PageThumbnailAdapter
import org.koitharu.kotatsu.utils.UiUtils
import org.koitharu.kotatsu.utils.ext.resolveDp
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
import org.koitharu.kotatsu.utils.ext.withArgs
@@ -24,34 +25,58 @@ import org.koitharu.kotatsu.utils.ext.withArgs
class PagesThumbnailsSheet : BaseBottomSheet<SheetPagesBinding>(),
OnListItemClickListener<MangaPage> {
private lateinit var thumbnails: List<PageThumbnail>
private val spanResolver = MangaListSpanResolver()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val pages = arguments?.getParcelableArrayList<MangaPage>(ARG_PAGES)
if (pages.isNullOrEmpty()) {
dismissAllowingStateLoss()
return
}
val current = arguments?.getInt(ARG_CURRENT, -1) ?: -1
val repository = pages.first().source.repository
thumbnails = pages.mapIndexed { i, x ->
PageThumbnail(
number = i + 1,
isCurrent = i == current,
repository = repository,
page = x
)
}
}
override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): SheetPagesBinding {
return SheetPagesBinding.inflate(inflater, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.recyclerView.addItemDecoration(SpacingItemDecoration(view.resources.resolveDp(8)))
val pages = arguments?.getParcelableArrayList<MangaPage>(ARG_PAGES)
if (pages == null) {
dismissAllowingStateLoss()
return
with(binding.recyclerView) {
addItemDecoration(SpacingItemDecoration(view.resources.resolveDp(8)))
adapter = PageThumbnailAdapter(
thumbnails,
get(),
viewLifecycleScope,
get(),
this@PagesThumbnailsSheet
)
addOnLayoutChangeListener(spanResolver)
spanResolver.setGridSize(get<AppSettings>().gridSize / 100f, this)
}
binding.recyclerView.adapter =
PageThumbnailAdapter(get(), viewLifecycleScope, get(), this).apply {
items = pages
}
val title = arguments?.getString(ARG_TITLE)
binding.toolbar.title = title
binding.toolbar.setNavigationOnClickListener { dismiss() }
binding.toolbar.subtitle =
resources.getQuantityString(R.plurals.pages, pages.size, pages.size)
resources.getQuantityString(R.plurals.pages, thumbnails.size, thumbnails.size)
binding.textViewTitle.text = title
if (dialog !is BottomSheetDialog) {
binding.toolbar.isVisible = true
binding.textViewTitle.isVisible = false
binding.appbar.elevation = resources.getDimension(R.dimen.elevation_large)
}
binding.recyclerView.addOnLayoutChangeListener(UiUtils.SpanCountResolver)
}
override fun onCreateDialog(savedInstanceState: Bundle?) =
@@ -77,11 +102,6 @@ class PagesThumbnailsSheet : BaseBottomSheet<SheetPagesBinding>(),
}
override fun onDestroyView() {
binding.recyclerView.adapter = null
super.onDestroyView()
}
override fun onItemClick(item: MangaPage, view: View) {
((parentFragment as? OnPageSelectListener)
?: (activity as? OnPageSelectListener))?.run {
@@ -94,13 +114,15 @@ class PagesThumbnailsSheet : BaseBottomSheet<SheetPagesBinding>(),
private const val ARG_PAGES = "pages"
private const val ARG_TITLE = "title"
private const val ARG_CURRENT = "current"
private const val TAG = "PagesThumbnailsSheet"
fun show(fm: FragmentManager, pages: List<MangaPage>, title: String) =
PagesThumbnailsSheet().withArgs(2) {
fun show(fm: FragmentManager, pages: List<MangaPage>, title: String, currentPage: Int) =
PagesThumbnailsSheet().withArgs(3) {
putParcelableArrayList(ARG_PAGES, ArrayList<MangaPage>(pages))
putString(ARG_TITLE, title)
putInt(ARG_CURRENT, currentPage)
}.show(fm, TAG)
}

View File

@@ -11,6 +11,7 @@ import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.MangaPage
import org.koitharu.kotatsu.databinding.ItemPageThumbBinding
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail
import org.koitharu.kotatsu.utils.ext.IgnoreErrors
fun pageThumbnailAD(
@@ -18,7 +19,7 @@ fun pageThumbnailAD(
scope: CoroutineScope,
cache: PagesCache,
clickListener: OnListItemClickListener<MangaPage>
) = adapterDelegateViewBinding<MangaPage, MangaPage, ItemPageThumbBinding>(
) = adapterDelegateViewBinding<PageThumbnail, PageThumbnail, ItemPageThumbBinding>(
{ inflater, parent -> ItemPageThumbBinding.inflate(inflater, parent, false) }
) {
@@ -30,16 +31,19 @@ fun pageThumbnailAD(
)
binding.handle.setOnClickListener {
clickListener.onItemClick(item, itemView)
clickListener.onItemClick(item.page, itemView)
}
bind {
job?.cancel()
binding.imageViewThumb.setImageDrawable(null)
binding.textViewNumber.text = (bindingAdapterPosition + 1).toString()
with(binding.textViewNumber) {
setBackgroundResource(if (item.isCurrent) R.drawable.bg_badge_accent else R.drawable.bg_badge_default)
text = (item.number).toString()
}
job = scope.launch(Dispatchers.Default + IgnoreErrors) {
val url = item.preview ?: item.url.let {
val pageUrl = item.source.repository.getPageFullUrl(item)
val url = item.page.preview ?: item.page.url.let {
val pageUrl = item.repository.getPageFullUrl(item.page)
cache[pageUrl]?.toUri()?.toString() ?: pageUrl
}
val drawable = coil.execute(

View File

@@ -6,15 +6,18 @@ import kotlinx.coroutines.CoroutineScope
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.MangaPage
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail
class PageThumbnailAdapter(
dataSet: List<PageThumbnail>,
coil: ImageLoader,
scope: CoroutineScope,
cache: PagesCache,
clickListener: OnListItemClickListener<MangaPage>
) : ListDelegationAdapter<List<MangaPage>>() {
) : ListDelegationAdapter<List<PageThumbnail>>() {
init {
delegatesManager.addDelegate(pageThumbnailAD(coil, scope, cache, clickListener))
setItems(dataSet)
}
}

View File

@@ -1,14 +0,0 @@
package org.koitharu.kotatsu.reader.ui.wetoon
import android.view.ViewGroup
import org.koitharu.kotatsu.reader.ui.PageLoader
import org.koitharu.kotatsu.reader.ui.base.BaseReaderAdapter
import org.koitharu.kotatsu.reader.ui.base.ReaderPage
class WebtoonAdapter(
pages: List<ReaderPage>,
private val loader: PageLoader
) : BaseReaderAdapter(pages) {
override fun onCreateViewHolder(parent: ViewGroup) = WebtoonHolder(parent, loader)
}

View File

@@ -1,91 +0,0 @@
package org.koitharu.kotatsu.reader.ui.wetoon
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.animation.AccelerateDecelerateInterpolator
import org.koitharu.kotatsu.databinding.FragmentReaderWebtoonBinding
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.base.AbstractReader
import org.koitharu.kotatsu.reader.ui.base.BaseReaderAdapter
import org.koitharu.kotatsu.reader.ui.base.ReaderPage
import org.koitharu.kotatsu.utils.ext.doOnCurrentItemChanged
import org.koitharu.kotatsu.utils.ext.findCenterViewPosition
import org.koitharu.kotatsu.utils.ext.firstItem
import org.koitharu.kotatsu.utils.ext.withArgs
class WebtoonReaderFragment : AbstractReader<FragmentReaderWebtoonBinding>() {
private val scrollInterpolator = AccelerateDecelerateInterpolator()
private var paginationListener: ListPaginationListener? = null
override fun onInflateView(
inflater: LayoutInflater,
container: ViewGroup?
) = FragmentReaderWebtoonBinding.inflate(inflater, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
paginationListener = ListPaginationListener(2, this)
with(binding.recyclerView) {
setHasFixedSize(true)
adapter = readerAdapter
addOnScrollListener(paginationListener!!)
doOnCurrentItemChanged(::notifyPageChanged)
}
}
override fun onCreateAdapter(dataSet: List<ReaderPage>): BaseReaderAdapter {
return WebtoonAdapter(dataSet, loader)
}
override fun recreateAdapter() {
super.recreateAdapter()
binding.recyclerView.swapAdapter(readerAdapter, true)
}
override fun onDestroyView() {
paginationListener = null
super.onDestroyView()
}
override fun getCurrentItem(): Int {
return binding.recyclerView.findCenterViewPosition()
}
override fun setCurrentItem(position: Int, isSmooth: Boolean) {
if (isSmooth) {
binding.recyclerView.smoothScrollToPosition(position)
} else {
binding.recyclerView.firstItem = position
}
}
override fun switchPageBy(delta: Int) {
binding.recyclerView.smoothScrollBy(
0,
(binding.recyclerView.height * 0.9).toInt() * delta,
scrollInterpolator
)
}
override fun getCurrentPageScroll(): Int {
return (binding.recyclerView.findViewHolderForAdapterPosition(getCurrentItem()) as? WebtoonHolder)
?.getScrollY() ?: 0
}
override fun restorePageScroll(position: Int, scroll: Int) {
binding.recyclerView.post {
val holder = binding.recyclerView.findViewHolderForAdapterPosition(position) ?: return@post
(holder as WebtoonHolder).restoreScroll(scroll)
}
}
companion object {
fun newInstance(state: ReaderState) = WebtoonReaderFragment().withArgs(1) {
putParcelable(ARG_STATE, state)
}
}
}

View File

@@ -1,12 +1,11 @@
package org.koitharu.kotatsu.remotelist.ui
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.flowOn
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga
@@ -16,6 +15,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.list.ui.MangaFilterConfig
import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.utils.ext.asLiveData
import java.util.*
class RemoteListViewModel(
@@ -49,9 +49,7 @@ class RemoteListViewModel(
result
}
}
}.onStart {
emit(listOf(LoadingState))
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
}.flowOn(Dispatchers.Default).asLiveData(viewModelScope.coroutineContext, listOf(LoadingState))
init {
loadList(false)

View File

@@ -1,18 +1,18 @@
package org.koitharu.kotatsu.search.ui
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.flowOn
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.utils.ext.asLiveData
import java.util.*
class SearchViewModel(
@@ -46,9 +46,7 @@ class SearchViewModel(
result
}
}
}.onStart {
emit(listOf(LoadingState))
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
}.flowOn(Dispatchers.Default).asLiveData(viewModelScope.coroutineContext, listOf(LoadingState))
init {
loadList(append = false)

View File

@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.search.ui.global
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -12,6 +11,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.search.domain.MangaSearchRepository
import org.koitharu.kotatsu.utils.ext.asLiveData
import org.koitharu.kotatsu.utils.ext.onFirst
import java.util.*
@@ -46,9 +46,7 @@ class GlobalSearchViewModel(
result
}
}
}.onStart {
emit(listOf(LoadingState))
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
}.flowOn(Dispatchers.Default).asLiveData(viewModelScope.coroutineContext, listOf(LoadingState))
init {
onRefresh()

View File

@@ -2,20 +2,20 @@ package org.koitharu.kotatsu.tracker.ui
import android.content.Context
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.flowOn
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.model.TrackingLogItem
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.tracker.ui.model.toFeedItem
import org.koitharu.kotatsu.utils.ext.asLiveData
import org.koitharu.kotatsu.utils.ext.mapItems
class FeedViewModel(
@@ -35,9 +35,7 @@ class FeedViewModel(
hasNextPage
) { list, isHasNextPage ->
if (isHasNextPage && list.isNotEmpty()) list + LoadingFooter else list
}.onStart {
emit(listOf(LoadingState))
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
}.flowOn(Dispatchers.Default).asLiveData(viewModelScope.coroutineContext, listOf(LoadingState))
init {
loadList(append = false)

View File

@@ -0,0 +1,6 @@
package org.koitharu.kotatsu.utils
fun interface BufferedObserver<T> {
fun onChanged(t: T, previous: T?)
}

View File

@@ -0,0 +1,54 @@
package org.koitharu.kotatsu.utils
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.coroutines.CoroutineContext
class FlowLiveEvent<T>(
private val source: Flow<T>,
private val context: CoroutineContext
) : LiveData<T>() {
private val scope = CoroutineScope(
Dispatchers.Main.immediate + context + SupervisorJob(context[Job])
)
private val pending = AtomicBoolean(false)
private var collectJob: Job? = null
override fun observe(owner: LifecycleOwner, observer: Observer<in T>) {
super.observe(owner) {
if (pending.compareAndSet(true, false)) {
observer.onChanged(it)
}
}
}
override fun onActive() {
super.onActive()
if (collectJob == null) {
collectJob = source.onEach {
setValue(it)
}.launchIn(scope)
}
}
override fun onInactive() {
collectJob?.cancel()
collectJob = null
super.onInactive()
}
override fun setValue(value: T) {
pending.set(true)
super.setValue(value)
}
}

View File

@@ -26,6 +26,7 @@ object UiUtils : KoinComponent {
fun isTablet(context: Context) = context.resources.getBoolean(R.bool.is_tablet)
@Deprecated("Use MangaListSpanResolver")
object SpanCountResolver : View.OnLayoutChangeListener {
override fun onLayoutChange(
v: View?, left: Int, top: Int, right: Int, bottom: Int,

View File

@@ -3,6 +3,13 @@ package org.koitharu.kotatsu.utils.ext
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import androidx.lifecycle.liveData
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import org.koitharu.kotatsu.utils.BufferedObserver
import org.koitharu.kotatsu.utils.FlowLiveEvent
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
fun <T> LiveData<T?>.observeNotNull(owner: LifecycleOwner, observer: Observer<T>) {
this.observe(owner) {
@@ -10,4 +17,28 @@ fun <T> LiveData<T?>.observeNotNull(owner: LifecycleOwner, observer: Observer<T>
observer.onChanged(it)
}
}
}
}
fun <T> LiveData<T>.observeWithPrevious(owner: LifecycleOwner, observer: BufferedObserver<T>) {
var previous: T? = null
this.observe(owner) {
observer.onChanged(it, previous)
previous = it
}
}
fun <T> Flow<T>.asLiveData(
context: CoroutineContext = EmptyCoroutineContext,
defaultValue: T
): LiveData<T> = liveData(context) {
if (latestValue == null) {
emit(defaultValue)
}
collect {
emit(it)
}
}
fun <T> Flow<T>.asLiveEvent(
context: CoroutineContext = EmptyCoroutineContext
): LiveData<T> = FlowLiveEvent(this, context)

View File

@@ -206,6 +206,6 @@ fun ViewPager2.swapAdapter(newAdapter: RecyclerView.Adapter<*>?) {
val position = currentItem
adapter = newAdapter
if (adapter != null && position != RecyclerView.NO_POSITION) {
currentItem = position
setCurrentItem(position, false)
}
}

View File

@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.widget.shelf.model.CategoryItem
@@ -26,7 +27,7 @@ class ShelfConfigViewModel(
CategoryItem(it.id, it.title, selectedId == it.id)
}
list
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
}.flowOn(Dispatchers.Default).asLiveData(viewModelScope.coroutineContext)
var checkedId: Long by selectedCategoryId::value
}