Refactor manga loading

pull/377/head
Koitharu 3 years ago
parent bfa9feaef0
commit dc358ae6a2
No known key found for this signature in database
GPG Key ID: 8E861F8CE6E7CE27

@ -0,0 +1,76 @@
package org.koitharu.kotatsu.core.model
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.reader.data.filterChapters
data class DoubleManga(
private val remoteManga: Result<Manga>?,
private val localManga: Result<Manga>?,
) {
constructor(manga: Manga) : this(
remoteManga = if (manga.source != MangaSource.LOCAL) Result.success(manga) else null,
localManga = if (manga.source == MangaSource.LOCAL) Result.success(manga) else null,
)
val remote: Manga?
get() = remoteManga?.getOrNull()
val local: Manga?
get() = localManga?.getOrNull()
val any: Manga?
get() = remote ?: local
val hasRemote: Boolean
get() = remoteManga?.isSuccess == true
val hasLocal: Boolean
get() = localManga?.isSuccess == true
val chapters: List<MangaChapter>? by lazy(LazyThreadSafetyMode.PUBLICATION) {
mergeChapters()
}
fun requireAny(): Manga {
val result = remoteManga?.getOrNull() ?: localManga?.getOrNull()
if (result != null) {
return result
}
throw (
remoteManga?.exceptionOrNull()
?: localManga?.exceptionOrNull()
?: IllegalStateException("No online either local manga available")
)
}
fun filterChapters(branch: String?) = DoubleManga(
remoteManga?.map { it.filterChapters(branch) },
localManga?.map { it.filterChapters(branch) },
)
private fun mergeChapters(): List<MangaChapter>? {
val remoteChapters = remote?.chapters
val localChapters = local?.chapters
if (localChapters == null && remoteChapters == null) {
return null
}
val localMap = if (!localChapters.isNullOrEmpty()) {
localChapters.associateByTo(LinkedHashMap(localChapters.size)) { it.id }
} else {
null
}
val result = ArrayList<MangaChapter>(maxOf(remoteChapters?.size ?: 0, localChapters?.size ?: 0))
remoteChapters?.forEach { r ->
localMap?.remove(r.id)?.let { l ->
result.add(l)
} ?: result.add(r)
}
localMap?.values?.let {
result.addAll(it)
}
return result
}
}

@ -5,6 +5,7 @@ import org.koitharu.kotatsu.core.util.ext.iterator
import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
fun Collection<Manga>.ids() = mapToSet { it.id } fun Collection<Manga>.ids() = mapToSet { it.id }
@ -54,3 +55,6 @@ fun Manga.getPreferredBranch(history: MangaHistory?): String? {
} }
return candidates.ifEmpty { groups }.maxByOrNull { it.value.size }?.key return candidates.ifEmpty { groups }.maxByOrNull { it.value.size }?.key
} }
val Manga.isLocal: Boolean
get() = source == MangaSource.LOCAL

@ -12,28 +12,31 @@ import org.koitharu.kotatsu.parsers.model.Manga
class MangaIntent private constructor( class MangaIntent private constructor(
@JvmField val manga: Manga?, @JvmField val manga: Manga?,
@JvmField val mangaId: Long, @JvmField val id: Long,
@JvmField val uri: Uri?, @JvmField val uri: Uri?,
) { ) {
constructor(intent: Intent?) : this( constructor(intent: Intent?) : this(
manga = intent?.getParcelableExtraCompat<ParcelableManga>(KEY_MANGA)?.manga, manga = intent?.getParcelableExtraCompat<ParcelableManga>(KEY_MANGA)?.manga,
mangaId = intent?.getLongExtra(KEY_ID, ID_NONE) ?: ID_NONE, id = intent?.getLongExtra(KEY_ID, ID_NONE) ?: ID_NONE,
uri = intent?.data, uri = intent?.data,
) )
constructor(savedStateHandle: SavedStateHandle) : this( constructor(savedStateHandle: SavedStateHandle) : this(
manga = savedStateHandle.get<ParcelableManga>(KEY_MANGA)?.manga, manga = savedStateHandle.get<ParcelableManga>(KEY_MANGA)?.manga,
mangaId = savedStateHandle[KEY_ID] ?: ID_NONE, id = savedStateHandle[KEY_ID] ?: ID_NONE,
uri = savedStateHandle[BaseActivity.EXTRA_DATA], uri = savedStateHandle[BaseActivity.EXTRA_DATA],
) )
constructor(args: Bundle?) : this( constructor(args: Bundle?) : this(
manga = args?.getParcelableCompat<ParcelableManga>(KEY_MANGA)?.manga, manga = args?.getParcelableCompat<ParcelableManga>(KEY_MANGA)?.manga,
mangaId = args?.getLong(KEY_ID, ID_NONE) ?: ID_NONE, id = args?.getLong(KEY_ID, ID_NONE) ?: ID_NONE,
uri = null, uri = null,
) )
val mangaId: Long
get() = if (id != ID_NONE) id else manga?.id ?: uri?.lastPathSegment?.toLongOrNull() ?: ID_NONE
companion object { companion object {
const val ID_NONE = 0L const val ID_NONE = 0L

@ -0,0 +1,94 @@
package org.koitharu.kotatsu.details.domain
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.core.model.DoubleManga
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.local.data.LocalManga
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import java.io.IOException
import javax.inject.Inject
class DetailsInteractor @Inject constructor(
private val historyRepository: HistoryRepository,
private val favouritesRepository: FavouritesRepository,
private val localMangaRepository: LocalMangaRepository,
private val trackingRepository: TrackingRepository,
private val settings: AppSettings,
private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
) {
fun observeIsFavourite(mangaId: Long): Flow<Boolean> {
return favouritesRepository.observeCategoriesIds(mangaId)
.map { it.isNotEmpty() }
}
fun observeNewChapters(mangaId: Long): Flow<Int> {
return settings.observeAsFlow(AppSettings.KEY_TRACKER_ENABLED) { isTrackerEnabled }
.flatMapLatest { isEnabled ->
if (isEnabled) {
trackingRepository.observeNewChaptersCount(mangaId)
} else {
flowOf(0)
}
}
}
fun observeScrobblingInfo(mangaId: Long): Flow<List<ScrobblingInfo>> {
return combine(
scrobblers.map { it.observeScrobblingInfo(mangaId) },
) { scrobblingInfo ->
scrobblingInfo.filterNotNull()
}
}
suspend fun deleteLocalManga(manga: Manga) {
val victim = if (manga.isLocal) manga else localMangaRepository.findSavedManga(manga)?.manga
checkNotNull(victim) { "Cannot find saved manga for ${manga.title}" }
val original = if (manga.isLocal) localMangaRepository.getRemoteManga(manga) else manga
localMangaRepository.delete(victim) || throw IOException("Unable to delete file")
runCatchingCancellable {
historyRepository.deleteOrSwap(victim, original)
}.onFailure {
it.printStackTraceDebug()
}
}
fun observeIncognitoMode(mangaFlow: Flow<Manga?>): Flow<Boolean> {
return mangaFlow
.distinctUntilChangedBy { it?.isNsfw }
.flatMapLatest { manga ->
if (manga != null) {
historyRepository.observeShouldSkip(manga)
} else {
settings.observeAsFlow(AppSettings.KEY_INCOGNITO_MODE) { isIncognitoModeEnabled }
}
}
}
suspend fun updateLocal(subject: DoubleManga?, localManga: LocalManga): DoubleManga? {
return if (subject?.any?.id == localManga.manga.id) {
subject.copy(
localManga = runCatchingCancellable {
localMangaRepository.getDetails(localManga.manga)
},
)
} else {
subject
}
}
}

@ -160,12 +160,14 @@ class ChaptersFragment :
val selectedIds = selectionController?.peekCheckedIds() ?: return false val selectedIds = selectionController?.peekCheckedIds() ?: return false
val allItems = chaptersAdapter?.items.orEmpty() val allItems = chaptersAdapter?.items.orEmpty()
val items = allItems.withIndex().filter { (_, x) -> x.chapter.id in selectedIds } val items = allItems.withIndex().filter { (_, x) -> x.chapter.id in selectedIds }
menu.findItem(R.id.action_save).isVisible = items.none { (_, x) -> var canSave = true
x.chapter.source == MangaSource.LOCAL var canDelete = true
} items.forEach { (_, x) ->
menu.findItem(R.id.action_delete).isVisible = items.all { (_, x) -> val isLocal = x.isDownloaded || x.chapter.source == MangaSource.LOCAL
x.chapter.source == MangaSource.LOCAL if (isLocal) canSave = false else canDelete = false
} }
menu.findItem(R.id.action_save).isVisible = canSave
menu.findItem(R.id.action_delete).isVisible = canDelete
menu.findItem(R.id.action_select_all).isVisible = items.size < allItems.size menu.findItem(R.id.action_select_all).isVisible = items.size < allItems.size
menu.findItem(R.id.action_mark_current).isVisible = items.size == 1 menu.findItem(R.id.action_mark_current).isVisible = items.size == 1
mode.title = items.size.toString() mode.title = items.size.toString()

@ -52,7 +52,7 @@ fun mapChapters(
isCurrent = chapter.id == currentId, isCurrent = chapter.id == currentId,
isUnread = isUnread, isUnread = isUnread,
isNew = false, isNew = false,
isDownloaded = false, isDownloaded = remoteManga != null,
) )
} }
} }

@ -8,6 +8,7 @@ import androidx.core.net.toUri
import androidx.core.text.getSpans import androidx.core.text.getSpans
import androidx.core.text.parseAsHtml import androidx.core.text.parseAsHtml
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.asFlow import androidx.lifecycle.asFlow
import androidx.lifecycle.asLiveData import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@ -25,82 +26,74 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.flow.update
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
import org.koitharu.kotatsu.core.model.DoubleManga
import org.koitharu.kotatsu.core.model.getPreferredBranch
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.SingleLiveEvent import org.koitharu.kotatsu.core.util.SingleLiveEvent
import org.koitharu.kotatsu.core.util.asFlowLiveData import org.koitharu.kotatsu.core.util.asFlowLiveData
import org.koitharu.kotatsu.core.util.ext.computeSize import org.koitharu.kotatsu.core.util.ext.computeSize
import org.koitharu.kotatsu.core.util.ext.requireValue
import org.koitharu.kotatsu.core.util.ext.toFileOrNull import org.koitharu.kotatsu.core.util.ext.toFileOrNull
import org.koitharu.kotatsu.details.domain.BranchComparator import org.koitharu.kotatsu.details.domain.BranchComparator
import org.koitharu.kotatsu.details.domain.DetailsInteractor
import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.HistoryInfo import org.koitharu.kotatsu.details.ui.model.HistoryInfo
import org.koitharu.kotatsu.details.ui.model.MangaBranch import org.koitharu.kotatsu.details.ui.model.MangaBranch
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.local.data.LocalManga import org.koitharu.kotatsu.local.data.LocalManga
import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.local.domain.DoubleMangaLoader
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class DetailsViewModel @Inject constructor( class DetailsViewModel @Inject constructor(
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
favouritesRepository: FavouritesRepository,
private val localMangaRepository: LocalMangaRepository,
trackingRepository: TrackingRepository,
private val bookmarksRepository: BookmarksRepository, private val bookmarksRepository: BookmarksRepository,
private val settings: AppSettings, private val settings: AppSettings,
private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>, private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
private val imageGetter: Html.ImageGetter, private val imageGetter: Html.ImageGetter,
private val delegate: MangaDetailsDelegate,
@LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>, @LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>,
private val downloadScheduler: DownloadWorker.Scheduler, private val downloadScheduler: DownloadWorker.Scheduler,
private val interactor: DetailsInteractor,
savedStateHandle: SavedStateHandle,
private val mangaLoader: DoubleMangaLoader,
) : BaseViewModel() { ) : BaseViewModel() {
private val intent = MangaIntent(savedStateHandle)
private val mangaId = intent.mangaId
private val doubleManga: MutableStateFlow<DoubleManga?> = MutableStateFlow(intent.manga?.let { DoubleManga(it) })
private var loadingJob: Job private var loadingJob: Job
val onShowToast = SingleLiveEvent<Int>() val onShowToast = SingleLiveEvent<Int>()
val onDownloadStarted = SingleLiveEvent<Unit>() val onDownloadStarted = SingleLiveEvent<Unit>()
private val mangaData = combine( private val mangaData = doubleManga.map { it?.any }
delegate.onlineManga, .stateIn(viewModelScope, SharingStarted.Eagerly, doubleManga.value?.any)
delegate.localManga,
) { o, l ->
o ?: l
}.stateIn(viewModelScope, SharingStarted.Lazily, null)
private val history = historyRepository.observeOne(delegate.mangaId) private val history = historyRepository.observeOne(mangaId)
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
private val favourite = favouritesRepository.observeCategoriesIds(delegate.mangaId).map { it.isNotEmpty() } private val favourite = interactor.observeIsFavourite(mangaId)
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
private val newChapters = settings.observeAsFlow(AppSettings.KEY_TRACKER_ENABLED) { isTrackerEnabled } private val newChapters = interactor.observeNewChapters(mangaId)
.flatMapLatest { isEnabled -> .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0)
if (isEnabled) {
trackingRepository.observeNewChaptersCount(delegate.mangaId)
} else {
flowOf(0)
}
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0)
private val chaptersQuery = MutableStateFlow("") private val chaptersQuery = MutableStateFlow("")
private val selectedBranch = MutableStateFlow<String?>(null)
private val chaptersReversed = settings.observeAsFlow(AppSettings.KEY_REVERSE_CHAPTERS) { chaptersReverse } private val chaptersReversed = settings.observeAsFlow(AppSettings.KEY_REVERSE_CHAPTERS) { chaptersReverse }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
@ -112,9 +105,9 @@ class DetailsViewModel @Inject constructor(
val historyInfo: LiveData<HistoryInfo> = combine( val historyInfo: LiveData<HistoryInfo> = combine(
mangaData, mangaData,
delegate.selectedBranch, selectedBranch,
history, history,
historyRepository.observeShouldSkip(mangaData), interactor.observeIncognitoMode(mangaData),
) { m, b, h, im -> ) { m, b, h, im ->
HistoryInfo(m, b, h, im) HistoryInfo(m, b, h, im)
}.asFlowLiveData( }.asFlowLiveData(
@ -126,10 +119,11 @@ class DetailsViewModel @Inject constructor(
if (it != null) bookmarksRepository.observeBookmarks(it) else flowOf(emptyList()) if (it != null) bookmarksRepository.observeBookmarks(it) else flowOf(emptyList())
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList()) }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList())
val localSize = delegate.localManga val localSize = doubleManga
.map { .map {
if (it != null) { val local = it?.local
val file = it.url.toUri().toFileOrNull() if (local != null) {
val file = local.url.toUri().toFileOrNull()
file?.computeSize() ?: 0L file?.computeSize() ?: 0L
} else { } else {
0L 0L
@ -152,46 +146,38 @@ class DetailsViewModel @Inject constructor(
val isScrobblingAvailable: Boolean val isScrobblingAvailable: Boolean
get() = scrobblers.any { it.isAvailable } get() = scrobblers.any { it.isAvailable }
val scrobblingInfo: LiveData<List<ScrobblingInfo>> = combine( val scrobblingInfo: LiveData<List<ScrobblingInfo>> = interactor.observeScrobblingInfo(mangaId)
scrobblers.map { it.observeScrobblingInfo(delegate.mangaId) }, .asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList())
) { scrobblingInfo ->
scrobblingInfo.filterNotNull()
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList())
val branches: LiveData<List<MangaBranch>> = combine( val branches: LiveData<List<MangaBranch>> = combine(
delegate.onlineManga, doubleManga,
delegate.localManga, selectedBranch,
delegate.selectedBranch, ) { m, b ->
) { m, l, b -> val chapters = m?.chapters
val chapters = concat(m?.chapters, l?.chapters) if (chapters.isNullOrEmpty()) return@combine emptyList()
if (chapters.isEmpty()) return@combine emptyList()
chapters.groupBy { x -> x.branch } chapters.groupBy { x -> x.branch }
.map { x -> MangaBranch(x.key, x.value.size, x.key == b) } .map { x -> MangaBranch(x.key, x.value.size, x.key == b) }
.sortedWith(BranchComparator()) .sortedWith(BranchComparator())
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList()) }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList())
val selectedBranchName = delegate.selectedBranch val selectedBranchName = selectedBranch
.asFlowLiveData(viewModelScope.coroutineContext, null) .asFlowLiveData(viewModelScope.coroutineContext, null)
val isChaptersEmpty: LiveData<Boolean> = combine( val isChaptersEmpty: LiveData<Boolean> = combine(
delegate.onlineManga, doubleManga,
delegate.localManga,
isLoading.asFlow(), isLoading.asFlow(),
) { manga, local, loading -> ) { manga, loading ->
(manga != null && manga.chapters.isNullOrEmpty()) && manga?.any != null && manga.chapters.isNullOrEmpty() && !loading
(local != null && local.chapters.isNullOrEmpty()) &&
!loading
}.asFlowLiveData(viewModelScope.coroutineContext, false) }.asFlowLiveData(viewModelScope.coroutineContext, false)
val chapters = combine( val chapters = combine(
combine( combine(
delegate.onlineManga, doubleManga,
delegate.localManga,
history, history,
delegate.selectedBranch, selectedBranch,
newChapters, newChapters,
) { manga, local, history, branch, news -> ) { manga, history, branch, news ->
mapChapters(manga, local, history, news, branch) mapChapters(manga?.remote, manga?.local, history, news, branch)
}, },
chaptersReversed, chaptersReversed,
chaptersQuery, chaptersQuery,
@ -200,7 +186,7 @@ class DetailsViewModel @Inject constructor(
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default) }.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
val selectedBranchValue: String? val selectedBranchValue: String?
get() = delegate.selectedBranch.value get() = selectedBranch.value
init { init {
loadingJob = doLoad() loadingJob = doLoad()
@ -216,20 +202,14 @@ class DetailsViewModel @Inject constructor(
} }
fun deleteLocal() { fun deleteLocal() {
val m = delegate.localManga.value val m = doubleManga.value?.local
if (m == null) { if (m == null) {
onShowToast.call(R.string.file_not_found) onShowToast.call(R.string.file_not_found)
return return
} }
launchLoadingJob(Dispatchers.Default) { launchLoadingJob(Dispatchers.Default) {
val manga = if (m.source == MangaSource.LOCAL) m else localMangaRepository.findSavedManga(m)?.manga interactor.deleteLocalManga(m)
checkNotNull(manga) { "Cannot find saved manga for ${m.title}" } onMangaRemoved.emitCall(m)
val original = localMangaRepository.getRemoteManga(manga)
localMangaRepository.delete(manga) || throw IOException("Unable to delete file")
runCatchingCancellable {
historyRepository.deleteOrSwap(manga, original)
}
onMangaRemoved.emitCall(manga)
} }
} }
@ -245,11 +225,7 @@ class DetailsViewModel @Inject constructor(
} }
fun setSelectedBranch(branch: String?) { fun setSelectedBranch(branch: String?) {
delegate.selectedBranch.value = branch selectedBranch.value = branch
}
fun getRemoteManga(): Manga? {
return delegate.onlineManga.value
} }
fun performChapterSearch(query: String?) { fun performChapterSearch(query: String?) {
@ -260,7 +236,7 @@ class DetailsViewModel @Inject constructor(
val scrobbler = getScrobbler(index) ?: return val scrobbler = getScrobbler(index) ?: return
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
scrobbler.updateScrobblingInfo( scrobbler.updateScrobblingInfo(
mangaId = delegate.mangaId, mangaId = mangaId,
rating = rating, rating = rating,
status = status, status = status,
comment = null, comment = null,
@ -272,26 +248,32 @@ class DetailsViewModel @Inject constructor(
val scrobbler = getScrobbler(index) ?: return val scrobbler = getScrobbler(index) ?: return
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
scrobbler.unregisterScrobbling( scrobbler.unregisterScrobbling(
mangaId = delegate.mangaId, mangaId = mangaId,
) )
} }
} }
fun markChapterAsCurrent(chapterId: Long) { fun markChapterAsCurrent(chapterId: Long) {
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
val manga = checkNotNull(mangaData.value) val manga = checkNotNull(doubleManga.value)
val chapters = checkNotNull(manga.getChapters(selectedBranchValue)) val chapters = checkNotNull(manga.filterChapters(selectedBranchValue).chapters)
val chapterIndex = chapters.indexOfFirst { it.id == chapterId } val chapterIndex = chapters.indexOfFirst { it.id == chapterId }
check(chapterIndex in chapters.indices) { "Chapter not found" } check(chapterIndex in chapters.indices) { "Chapter not found" }
val percent = chapterIndex / chapters.size.toFloat() val percent = chapterIndex / chapters.size.toFloat()
historyRepository.addOrUpdate(manga = manga, chapterId = chapterId, page = 0, scroll = 0, percent = percent) historyRepository.addOrUpdate(
manga = manga.requireAny(),
chapterId = chapterId,
page = 0,
scroll = 0,
percent = percent,
)
} }
} }
fun download(chaptersIds: Set<Long>?) { fun download(chaptersIds: Set<Long>?) {
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
downloadScheduler.schedule( downloadScheduler.schedule(
delegate.onlineManga.value ?: checkNotNull(manga.value), doubleManga.requireValue().requireAny(),
chaptersIds, chaptersIds,
) )
onDownloadStarted.emitCall(Unit) onDownloadStarted.emitCall(Unit)
@ -299,7 +281,12 @@ class DetailsViewModel @Inject constructor(
} }
private fun doLoad() = launchLoadingJob(Dispatchers.Default) { private fun doLoad() = launchLoadingJob(Dispatchers.Default) {
delegate.doLoad() val result = mangaLoader.load(intent)
val manga = result.requireAny()
// find default branch
val hist = historyRepository.getOne(manga)
selectedBranch.value = manga.getPreferredBranch(hist)
doubleManga.value = result
} }
private fun List<ChapterListItem>.filterSearch(query: String): List<ChapterListItem> { private fun List<ChapterListItem>.filterSearch(query: String): List<ChapterListItem> {
@ -313,21 +300,9 @@ class DetailsViewModel @Inject constructor(
private suspend fun onDownloadComplete(downloadedManga: LocalManga?) { private suspend fun onDownloadComplete(downloadedManga: LocalManga?) {
downloadedManga ?: return downloadedManga ?: return
val currentManga = mangaData.value ?: return launchJob {
if (currentManga.id != downloadedManga.manga.id) { doubleManga.update {
return interactor.updateLocal(it, downloadedManga)
}
if (currentManga.source == MangaSource.LOCAL) {
reload()
} else {
viewModelScope.launch(Dispatchers.Default) {
runCatchingCancellable {
localMangaRepository.getDetails(downloadedManga.manga)
}.onSuccess {
delegate.publishManga(it)
}.onFailure {
it.printStackTraceDebug()
}
} }
} }
} }
@ -353,18 +328,4 @@ class DetailsViewModel @Inject constructor(
} }
return scrobbler return scrobbler
} }
private fun <T> concat(a: List<T>?, b: List<T>?): List<T> {
return when {
a == null && b == null -> emptyList<T>()
a == null && b != null -> b
a != null && b == null -> a
a != null && b != null -> buildList<T>(a.size + b.size) {
addAll(a)
addAll(b)
}
else -> error("This shouldn't have happened")
}
}
} }

@ -1,89 +0,0 @@
package org.koitharu.kotatsu.details.ui
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.ViewModelLifecycle
import dagger.hilt.android.scopes.ViewModelScoped
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import org.koitharu.kotatsu.core.model.getPreferredBranch
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import javax.inject.Inject
@ViewModelScoped
class MangaDetailsDelegate @Inject constructor(
savedStateHandle: SavedStateHandle,
lifecycle: ViewModelLifecycle,
private val mangaDataRepository: MangaDataRepository,
private val historyRepository: HistoryRepository,
private val localMangaRepository: LocalMangaRepository,
private val mangaRepositoryFactory: MangaRepository.Factory,
networkState: NetworkState,
) {
private val viewModelScope = RetainedLifecycleCoroutineScope(lifecycle)
private val intent = MangaIntent(savedStateHandle)
private val onlineMangaStateFlow = MutableStateFlow<Manga?>(null)
private val localMangaStateFlow = MutableStateFlow<Manga?>(null)
val onlineManga = combine(
onlineMangaStateFlow,
networkState,
) { m, s -> m.takeIf { s } }
.stateIn(viewModelScope, SharingStarted.Lazily, null)
val localManga = localMangaStateFlow.asStateFlow()
val selectedBranch = MutableStateFlow<String?>(null)
val mangaId = intent.manga?.id ?: intent.mangaId
init {
intent.manga?.let {
publishManga(it)
}
}
suspend fun doLoad() {
var manga = mangaDataRepository.resolveIntent(intent) ?: throw NotFoundException("Cannot find manga", "")
publishManga(manga)
manga = mangaRepositoryFactory.create(manga.source).getDetails(manga)
// find default branch
val hist = historyRepository.getOne(manga)
selectedBranch.value = manga.getPreferredBranch(hist)
publishManga(manga)
runCatchingCancellable {
if (manga.source == MangaSource.LOCAL) {
val m = localMangaRepository.getRemoteManga(manga) ?: return@runCatchingCancellable null
mangaRepositoryFactory.create(m.source).getDetails(m)
} else {
localMangaRepository.findSavedManga(manga)?.manga
}
}.onFailure { error ->
error.printStackTraceDebug()
}.onSuccess {
if (it != null) {
publishManga(it)
}
}
}
fun publishManga(manga: Manga) {
if (manga.source == MangaSource.LOCAL) {
localMangaStateFlow
} else {
onlineMangaStateFlow
}.value = manga
}
}

@ -4,9 +4,7 @@ import androidx.room.withTransaction
import dagger.Reusable import dagger.Reusable
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
@ -17,7 +15,6 @@ import org.koitharu.kotatsu.core.db.entity.toMangaTag
import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
import org.koitharu.kotatsu.core.util.ext.mapItems import org.koitharu.kotatsu.core.util.ext.mapItems
import org.koitharu.kotatsu.history.data.HistoryEntity import org.koitharu.kotatsu.history.data.HistoryEntity
@ -161,18 +158,6 @@ class HistoryRepository @Inject constructor(
.distinctUntilChanged() .distinctUntilChanged()
} }
fun observeShouldSkip(mangaFlow: Flow<Manga?>): Flow<Boolean> {
return mangaFlow
.distinctUntilChangedBy { it?.isNsfw }
.flatMapLatest { m ->
if (m != null) {
observeShouldSkip(m)
} else {
settings.observeAsFlow(AppSettings.KEY_INCOGNITO_MODE) { isIncognitoModeEnabled }
}
}
}
private suspend fun recover(ids: Collection<Long>) { private suspend fun recover(ids: Collection<Long>) {
db.withTransaction { db.withTransaction {
for (id in ids) { for (id in ids) {

@ -0,0 +1,66 @@
package org.koitharu.kotatsu.local.domain
import dagger.Reusable
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import org.koitharu.kotatsu.core.model.DoubleManga
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject
@Reusable
class DoubleMangaLoader @Inject constructor(
private val mangaDataRepository: MangaDataRepository,
private val localMangaRepository: LocalMangaRepository,
private val mangaRepositoryFactory: MangaRepository.Factory,
) {
suspend fun load(manga: Manga): DoubleManga = coroutineScope {
val remoteDeferred = async(Dispatchers.Default) { loadRemote(manga) }
val localDeferred = async(Dispatchers.Default) { loadLocal(manga) }
DoubleManga(
remoteManga = remoteDeferred.await(),
localManga = localDeferred.await(),
)
}
suspend fun load(mangaId: Long): DoubleManga {
val manga = mangaDataRepository.findMangaById(mangaId) ?: throwNFE()
return load(manga)
}
suspend fun load(intent: MangaIntent): DoubleManga {
val manga = mangaDataRepository.resolveIntent(intent) ?: throwNFE()
return load(manga)
}
private suspend fun loadLocal(manga: Manga): Result<Manga>? {
return runCatchingCancellable {
if (manga.isLocal) {
localMangaRepository.getDetails(manga)
} else {
localMangaRepository.findSavedManga(manga)?.manga
} ?: return null
}
}
private suspend fun loadRemote(manga: Manga): Result<Manga>? {
return runCatchingCancellable {
val seed = if (manga.isLocal) {
localMangaRepository.getRemoteManga(manga)
} else {
manga
} ?: return null
val repository = mangaRepositoryFactory.create(seed.source)
repository.getDetails(seed)
}
}
private fun throwNFE(): Nothing = throw NotFoundException("Cannot find manga", "")
}

@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.util.CompositeMutex import org.koitharu.kotatsu.core.util.CompositeMutex
import org.koitharu.kotatsu.core.util.ext.deleteAwait import org.koitharu.kotatsu.core.util.ext.deleteAwait
@ -99,8 +100,11 @@ class LocalMangaRepository @Inject constructor(
suspend fun deleteChapters(manga: Manga, ids: Set<Long>) { suspend fun deleteChapters(manga: Manga, ids: Set<Long>) {
lockManga(manga.id) lockManga(manga.id)
try { try {
LocalMangaUtil(manga).deleteChapters(ids) val subject = if (manga.isLocal) manga else checkNotNull(findSavedManga(manga)) {
localStorageChanges.emit(LocalManga(manga)) "Manga is not stored on local storage"
}.manga
LocalMangaUtil(subject).deleteChapters(ids)
localStorageChanges.emit(LocalManga(subject))
} finally { } finally {
unlockManga(manga.id) unlockManga(manga.id)
} }

@ -4,8 +4,8 @@ import android.util.LongSparseArray
import dagger.hilt.android.scopes.ViewModelScoped import dagger.hilt.android.scopes.ViewModelScoped
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import org.koitharu.kotatsu.core.model.DoubleManga
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import javax.inject.Inject import javax.inject.Inject
@ -17,17 +17,27 @@ class ChaptersLoader @Inject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaRepositoryFactory: MangaRepository.Factory,
) { ) {
val chapters = LongSparseArray<MangaChapter>() private val chapters = LongSparseArray<MangaChapter>()
private val chapterPages = ChapterPages() private val chapterPages = ChapterPages()
private val mutex = Mutex() private val mutex = Mutex()
suspend fun loadPrevNextChapter(manga: Manga, currentId: Long, isNext: Boolean) { val size: Int
get() = chapters.size()
suspend fun init(manga: DoubleManga) = mutex.withLock {
chapters.clear()
manga.chapters?.forEach {
chapters.put(it.id, it)
}
}
suspend fun loadPrevNextChapter(manga: DoubleManga, currentId: Long, isNext: Boolean) {
val chapters = manga.chapters ?: return val chapters = manga.chapters ?: return
val predicate: (MangaChapter) -> Boolean = { it.id == currentId } val predicate: (MangaChapter) -> Boolean = { it.id == currentId }
val index = if (isNext) chapters.indexOfFirst(predicate) else chapters.indexOfLast(predicate) val index = if (isNext) chapters.indexOfFirst(predicate) else chapters.indexOfLast(predicate)
if (index == -1) return if (index == -1) return
val newChapter = chapters.getOrNull(if (isNext) index + 1 else index - 1) ?: return val newChapter = chapters.getOrNull(if (isNext) index + 1 else index - 1) ?: return
val newPages = loadChapter(manga, newChapter.id) val newPages = loadChapter(newChapter.id)
mutex.withLock { mutex.withLock {
if (chapterPages.chaptersSize > 1) { if (chapterPages.chaptersSize > 1) {
// trim pages // trim pages
@ -47,14 +57,16 @@ class ChaptersLoader @Inject constructor(
} }
} }
suspend fun loadSingleChapter(manga: Manga, chapterId: Long) { suspend fun loadSingleChapter(chapterId: Long) {
val pages = loadChapter(manga, chapterId) val pages = loadChapter(chapterId)
mutex.withLock { mutex.withLock {
chapterPages.clear() chapterPages.clear()
chapterPages.addLast(chapterId, pages) chapterPages.addLast(chapterId, pages)
} }
} }
fun peekChapter(chapterId: Long): MangaChapter? = chapters[chapterId]
fun getPages(chapterId: Long): List<ReaderPage> { fun getPages(chapterId: Long): List<ReaderPage> {
return chapterPages.subList(chapterId) return chapterPages.subList(chapterId)
} }
@ -69,9 +81,9 @@ class ChaptersLoader @Inject constructor(
fun snapshot() = chapterPages.toList() fun snapshot() = chapterPages.toList()
private suspend fun loadChapter(manga: Manga, chapterId: Long): List<ReaderPage> { private suspend fun loadChapter(chapterId: Long): List<ReaderPage> {
val chapter = checkNotNull(chapters[chapterId]) { "Requested chapter not found" } val chapter = checkNotNull(chapters[chapterId]) { "Requested chapter not found" }
val repo = mangaRepositoryFactory.create(manga.source) val repo = mangaRepositoryFactory.create(chapter.source)
return repo.getPages(chapter).mapIndexed { index, page -> return repo.getPages(chapter).mapIndexed { index, page ->
ReaderPage(page, index, chapterId) ReaderPage(page, index, chapterId)
} }

@ -183,7 +183,7 @@ class ReaderActivity :
val state = viewModel.getCurrentState() ?: return false val state = viewModel.getCurrentState() ?: return false
PagesThumbnailsSheet.show( PagesThumbnailsSheet.show(
supportFragmentManager, supportFragmentManager,
viewModel.manga ?: return false, viewModel.manga?.any ?: return false,
state.chapterId, state.chapterId,
state.page, state.page,
) )

@ -1,7 +1,6 @@
package org.koitharu.kotatsu.reader.ui package org.koitharu.kotatsu.reader.ui
import android.net.Uri import android.net.Uri
import android.util.LongSparseArray
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.AnyThread import androidx.annotation.AnyThread
import androidx.annotation.MainThread import androidx.annotation.MainThread
@ -16,6 +15,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@ -33,6 +33,7 @@ import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
import org.koitharu.kotatsu.core.model.DoubleManga
import org.koitharu.kotatsu.core.os.ShortcutsUpdater import org.koitharu.kotatsu.core.os.ShortcutsUpdater
import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaIntent import org.koitharu.kotatsu.core.parser.MangaIntent
@ -50,12 +51,11 @@ import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.core.util.ext.requireValue import org.koitharu.kotatsu.core.util.ext.requireValue
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.local.domain.DoubleMangaLoader
import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.reader.data.filterChapters
import org.koitharu.kotatsu.reader.domain.ChaptersLoader import org.koitharu.kotatsu.reader.domain.ChaptersLoader
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
@ -79,6 +79,7 @@ class ReaderViewModel @Inject constructor(
private val pageLoader: PageLoader, private val pageLoader: PageLoader,
private val chaptersLoader: ChaptersLoader, private val chaptersLoader: ChaptersLoader,
private val shortcutsUpdater: ShortcutsUpdater, private val shortcutsUpdater: ShortcutsUpdater,
private val mangaLoader: DoubleMangaLoader,
) : BaseViewModel() { ) : BaseViewModel() {
private val intent = MangaIntent(savedStateHandle) private val intent = MangaIntent(savedStateHandle)
@ -90,9 +91,9 @@ class ReaderViewModel @Inject constructor(
private var bookmarkJob: Job? = null private var bookmarkJob: Job? = null
private var stateChangeJob: Job? = null private var stateChangeJob: Job? = null
private val currentState = MutableStateFlow<ReaderState?>(savedStateHandle[ReaderActivity.EXTRA_STATE]) private val currentState = MutableStateFlow<ReaderState?>(savedStateHandle[ReaderActivity.EXTRA_STATE])
private val mangaData = MutableStateFlow(intent.manga) private val mangaData = MutableStateFlow(intent.manga?.let { DoubleManga(it) })
private val chapters: LongSparseArray<MangaChapter> private val mangaFlow: Flow<Manga?>
get() = chaptersLoader.chapters get() = mangaData.map { it?.any }
val readerMode = MutableLiveData<ReaderMode>() val readerMode = MutableLiveData<ReaderMode>()
val onPageSaved = SingleLiveEvent<Uri?>() val onPageSaved = SingleLiveEvent<Uri?>()
@ -100,7 +101,7 @@ class ReaderViewModel @Inject constructor(
val uiState = MutableLiveData<ReaderUiState?>(null) val uiState = MutableLiveData<ReaderUiState?>(null)
val content = MutableLiveData(ReaderContent(emptyList(), null)) val content = MutableLiveData(ReaderContent(emptyList(), null))
val manga: Manga? val manga: DoubleManga?
get() = mangaData.value get() = mangaData.value
val readerAnimation = settings.observeAsLiveData( val readerAnimation = settings.observeAsLiveData(
@ -124,13 +125,13 @@ class ReaderViewModel @Inject constructor(
val readerSettings = ReaderSettings( val readerSettings = ReaderSettings(
parentScope = viewModelScope, parentScope = viewModelScope,
settings = settings, settings = settings,
colorFilterFlow = mangaData.flatMapLatest { colorFilterFlow = mangaFlow.flatMapLatest {
if (it == null) flowOf(null) else dataRepository.observeColorFilter(it.id) if (it == null) flowOf(null) else dataRepository.observeColorFilter(it.id)
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null), }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null),
) )
val isScreenshotsBlockEnabled = combine( val isScreenshotsBlockEnabled = combine(
mangaData, mangaFlow,
settings.observeAsFlow(AppSettings.KEY_SCREENSHOTS_POLICY) { screenshotsPolicy }, settings.observeAsFlow(AppSettings.KEY_SCREENSHOTS_POLICY) { screenshotsPolicy },
) { manga, policy -> ) { manga, policy ->
policy == ScreenshotsPolicy.BLOCK_ALL || policy == ScreenshotsPolicy.BLOCK_ALL ||
@ -138,7 +139,7 @@ class ReaderViewModel @Inject constructor(
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, false) }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, false)
val isBookmarkAdded: LiveData<Boolean> = currentState.flatMapLatest { state -> val isBookmarkAdded: LiveData<Boolean> = currentState.flatMapLatest { state ->
val manga = mangaData.value val manga = mangaData.value?.any
if (state == null || manga == null) { if (state == null || manga == null) {
flowOf(false) flowOf(false)
} else { } else {
@ -154,7 +155,7 @@ class ReaderViewModel @Inject constructor(
if (key == AppSettings.KEY_READER_SLIDER) notifyStateChanged() if (key == AppSettings.KEY_READER_SLIDER) notifyStateChanged()
}.launchIn(viewModelScope + Dispatchers.Default) }.launchIn(viewModelScope + Dispatchers.Default)
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
val mangaId = mangaData.filterNotNull().first().id val mangaId = mangaFlow.filterNotNull().first().id
shortcutsUpdater.notifyMangaOpened(mangaId) shortcutsUpdater.notifyMangaOpened(mangaId)
} }
} }
@ -166,7 +167,7 @@ class ReaderViewModel @Inject constructor(
fun switchMode(newMode: ReaderMode) { fun switchMode(newMode: ReaderMode) {
launchJob { launchJob {
val manga = checkNotNull(mangaData.value) val manga = checkNotNull(mangaData.value?.any)
dataRepository.saveReaderMode( dataRepository.saveReaderMode(
manga = manga, manga = manga,
mode = newMode, mode = newMode,
@ -189,7 +190,7 @@ class ReaderViewModel @Inject constructor(
} }
val readerState = state ?: currentState.value ?: return val readerState = state ?: currentState.value ?: return
historyRepository.saveStateAsync( historyRepository.saveStateAsync(
manga = mangaData.value ?: return, manga = mangaData.value?.any ?: return,
state = readerState, state = readerState,
percent = computePercent(readerState.chapterId, readerState.page), percent = computePercent(readerState.chapterId, readerState.page),
) )
@ -242,7 +243,7 @@ class ReaderViewModel @Inject constructor(
loadingJob = launchLoadingJob(Dispatchers.Default) { loadingJob = launchLoadingJob(Dispatchers.Default) {
prevJob?.cancelAndJoin() prevJob?.cancelAndJoin()
content.postValue(ReaderContent(emptyList(), null)) content.postValue(ReaderContent(emptyList(), null))
chaptersLoader.loadSingleChapter(mangaData.requireValue(), id) chaptersLoader.loadSingleChapter(id)
content.postValue(ReaderContent(chaptersLoader.snapshot(), ReaderState(id, page, 0))) content.postValue(ReaderContent(chaptersLoader.snapshot(), ReaderState(id, page, 0)))
} }
} }
@ -285,7 +286,7 @@ class ReaderViewModel @Inject constructor(
val state = checkNotNull(currentState.value) val state = checkNotNull(currentState.value)
val page = checkNotNull(getCurrentPage()) { "Page not found" } val page = checkNotNull(getCurrentPage()) { "Page not found" }
val bookmark = Bookmark( val bookmark = Bookmark(
manga = checkNotNull(mangaData.value), manga = checkNotNull(mangaData.value?.any),
pageId = page.id, pageId = page.id,
chapterId = state.chapterId, chapterId = state.chapterId,
page = state.page, page = state.page,
@ -305,7 +306,7 @@ class ReaderViewModel @Inject constructor(
} }
bookmarkJob = launchJob { bookmarkJob = launchJob {
loadingJob?.join() loadingJob?.join()
val manga = checkNotNull(mangaData.value) val manga = checkNotNull(mangaData.value?.any)
val page = checkNotNull(getCurrentPage()) { "Page not found" } val page = checkNotNull(getCurrentPage()) { "Page not found" }
bookmarksRepository.removeBookmark(manga.id, page.id) bookmarksRepository.removeBookmark(manga.id, page.id)
onShowToast.call(R.string.bookmark_removed) onShowToast.call(R.string.bookmark_removed)
@ -314,32 +315,31 @@ class ReaderViewModel @Inject constructor(
private fun loadImpl() { private fun loadImpl() {
loadingJob = launchLoadingJob(Dispatchers.Default) { loadingJob = launchLoadingJob(Dispatchers.Default) {
var manga = dataRepository.resolveIntent(intent) ?: throw NotFoundException("Cannot find manga", "") var manga =
DoubleManga(dataRepository.resolveIntent(intent) ?: throw NotFoundException("Cannot find manga", ""))
mangaData.value = manga mangaData.value = manga
val repo = mangaRepositoryFactory.create(manga.source) manga = mangaLoader.load(intent)
manga = repo.getDetails(manga) chaptersLoader.init(manga)
manga.chapters?.forEach {
chapters.put(it.id, it)
}
// determine mode // determine mode
val mode = detectReaderMode(manga, repo) val singleManga = manga.requireAny()
val mode = detectReaderMode(singleManga)
// obtain state // obtain state
if (currentState.value == null) { if (currentState.value == null) {
currentState.value = historyRepository.getOne(manga)?.let { currentState.value = historyRepository.getOne(singleManga)?.let {
ReaderState(it) ReaderState(it)
} ?: ReaderState(manga, preselectedBranch) } ?: ReaderState(singleManga, preselectedBranch)
} }
val branch = chapters[currentState.value?.chapterId ?: 0L]?.branch val branch = chaptersLoader.peekChapter(currentState.value?.chapterId ?: 0L)?.branch
mangaData.value = manga.filterChapters(branch) mangaData.value = manga.filterChapters(branch)
readerMode.emitValue(mode) readerMode.emitValue(mode)
chaptersLoader.loadSingleChapter(manga, requireNotNull(currentState.value).chapterId) chaptersLoader.loadSingleChapter(requireNotNull(currentState.value).chapterId)
// save state // save state
if (!isIncognito) { if (!isIncognito) {
currentState.value?.let { currentState.value?.let {
val percent = computePercent(it.chapterId, it.page) val percent = computePercent(it.chapterId, it.page)
historyRepository.addOrUpdate(manga, it.chapterId, it.page, it.scroll, percent) historyRepository.addOrUpdate(singleManga, it.chapterId, it.page, it.scroll, percent)
} }
} }
notifyStateChanged() notifyStateChanged()
@ -367,15 +367,16 @@ class ReaderViewModel @Inject constructor(
} }
} }
private suspend fun detectReaderMode(manga: Manga, repo: MangaRepository): ReaderMode { private suspend fun detectReaderMode(manga: Manga): ReaderMode {
dataRepository.getReaderMode(manga.id)?.let { return it } dataRepository.getReaderMode(manga.id)?.let { return it }
val defaultMode = settings.defaultReaderMode val defaultMode = settings.defaultReaderMode
if (!settings.isReaderModeDetectionEnabled || defaultMode == ReaderMode.WEBTOON) { if (!settings.isReaderModeDetectionEnabled || defaultMode == ReaderMode.WEBTOON) {
return defaultMode return defaultMode
} }
val chapter = currentState.value?.chapterId?.let(chapters::get) val chapter = currentState.value?.chapterId?.let { chaptersLoader.peekChapter(it) }
?: manga.chapters?.randomOrNull() ?: manga.chapters?.randomOrNull()
?: error("There are no chapters in this manga") ?: error("There are no chapters in this manga")
val repo = mangaRepositoryFactory.create(manga.source)
val pages = repo.getPages(chapter) val pages = repo.getPages(chapter)
return runCatchingCancellable { return runCatchingCancellable {
val isWebtoon = dataRepository.determineMangaIsWebtoon(repo, pages) val isWebtoon = dataRepository.determineMangaIsWebtoon(repo, pages)
@ -390,12 +391,12 @@ class ReaderViewModel @Inject constructor(
@WorkerThread @WorkerThread
private fun notifyStateChanged() { private fun notifyStateChanged() {
val state = getCurrentState() val state = getCurrentState()
val chapter = state?.chapterId?.let(chapters::get) val chapter = state?.chapterId?.let { chaptersLoader.peekChapter(it) }
val newState = ReaderUiState( val newState = ReaderUiState(
mangaName = manga?.title, mangaName = manga?.any?.title,
chapterName = chapter?.name, chapterName = chapter?.name,
chapterNumber = chapter?.number ?: 0, chapterNumber = chapter?.number ?: 0,
chaptersTotal = manga?.getChapters(chapter?.branch)?.size ?: 0, chaptersTotal = manga?.any?.getChapters(chapter?.branch)?.size ?: 0,
totalPages = if (chapter != null) chaptersLoader.getPagesCount(chapter.id) else 0, totalPages = if (chapter != null) chaptersLoader.getPagesCount(chapter.id) else 0,
currentPage = state?.page ?: 0, currentPage = state?.page ?: 0,
isSliderEnabled = settings.isReaderSliderEnabled, isSliderEnabled = settings.isReaderSliderEnabled,
@ -405,8 +406,8 @@ class ReaderViewModel @Inject constructor(
} }
private fun computePercent(chapterId: Long, pageIndex: Int): Float { private fun computePercent(chapterId: Long, pageIndex: Int): Float {
val branch = chapters[chapterId]?.branch val branch = chaptersLoader.peekChapter(chapterId)?.branch
val chapters = manga?.getChapters(branch) ?: return PROGRESS_NONE val chapters = manga?.any?.getChapters(branch) ?: return PROGRESS_NONE
val chaptersCount = chapters.size val chaptersCount = chapters.size
val chapterIndex = chapters.indexOfFirst { x -> x.id == chapterId } val chapterIndex = chapters.indexOfFirst { x -> x.id == chapterId }
val pagesCount = chaptersLoader.getPagesCount(chapterId) val pagesCount = chaptersLoader.getPagesCount(chapterId)

@ -108,7 +108,7 @@ class ReaderConfigBottomSheet :
R.id.button_color_filter -> { R.id.button_color_filter -> {
val page = viewModel.getCurrentPage() ?: return val page = viewModel.getCurrentPage() ?: return
val manga = viewModel.manga ?: return val manga = viewModel.manga?.any ?: return
startActivity(ColorFilterConfigActivity.newIntent(v.context, manga, page)) startActivity(ColorFilterConfigActivity.newIntent(v.context, manga, page))
} }
} }

@ -12,8 +12,8 @@ import org.koitharu.kotatsu.core.util.ext.emitValue
import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.local.domain.DoubleMangaLoader
import org.koitharu.kotatsu.parsers.util.SuspendLazy import org.koitharu.kotatsu.parsers.util.SuspendLazy
import org.koitharu.kotatsu.reader.data.filterChapters
import org.koitharu.kotatsu.reader.domain.ChaptersLoader import org.koitharu.kotatsu.reader.domain.ChaptersLoader
import javax.inject.Inject import javax.inject.Inject
@ -22,6 +22,7 @@ class PagesThumbnailsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
mangaRepositoryFactory: MangaRepository.Factory, mangaRepositoryFactory: MangaRepository.Factory,
private val chaptersLoader: ChaptersLoader, private val chaptersLoader: ChaptersLoader,
private val mangaLoader: DoubleMangaLoader,
) : BaseViewModel() { ) : BaseViewModel() {
private val currentPageIndex: Int = savedStateHandle[PagesThumbnailsSheet.ARG_CURRENT_PAGE] ?: -1 private val currentPageIndex: Int = savedStateHandle[PagesThumbnailsSheet.ARG_CURRENT_PAGE] ?: -1
@ -30,13 +31,9 @@ class PagesThumbnailsViewModel @Inject constructor(
private val repository = mangaRepositoryFactory.create(manga.source) private val repository = mangaRepositoryFactory.create(manga.source)
private val mangaDetails = SuspendLazy { private val mangaDetails = SuspendLazy {
repository.getDetails(manga).let { mangaLoader.load(manga).let {
chaptersLoader.chapters.clear()
val b = manga.chapters?.find { ch -> ch.id == initialChapterId }?.branch val b = manga.chapters?.find { ch -> ch.id == initialChapterId }?.branch
branch.emitValue(b) branch.emitValue(b)
it.getChapters(b)?.forEach { ch ->
chaptersLoader.chapters.put(ch.id, ch)
}
it.filterChapters(b) it.filterChapters(b)
} }
} }
@ -50,7 +47,8 @@ class PagesThumbnailsViewModel @Inject constructor(
init { init {
loadingJob = launchJob(Dispatchers.Default) { loadingJob = launchJob(Dispatchers.Default) {
chaptersLoader.loadSingleChapter(mangaDetails.get(), initialChapterId) chaptersLoader.init(mangaDetails.get())
chaptersLoader.loadSingleChapter(initialChapterId)
updateList() updateList()
} }
} }
@ -80,14 +78,14 @@ class PagesThumbnailsViewModel @Inject constructor(
val mangaChapters = mangaDetails.tryGet().getOrNull()?.chapters.orEmpty() val mangaChapters = mangaDetails.tryGet().getOrNull()?.chapters.orEmpty()
val hasPrevChapter = snapshot.firstOrNull()?.chapterId != mangaChapters.firstOrNull()?.id val hasPrevChapter = snapshot.firstOrNull()?.chapterId != mangaChapters.firstOrNull()?.id
val hasNextChapter = snapshot.lastOrNull()?.chapterId != mangaChapters.lastOrNull()?.id val hasNextChapter = snapshot.lastOrNull()?.chapterId != mangaChapters.lastOrNull()?.id
val pages = buildList(snapshot.size + chaptersLoader.chapters.size() + 2) { val pages = buildList(snapshot.size + chaptersLoader.size + 2) {
if (hasPrevChapter) { if (hasPrevChapter) {
add(LoadingFooter(-1)) add(LoadingFooter(-1))
} }
var previousChapterId = 0L var previousChapterId = 0L
for (page in snapshot) { for (page in snapshot) {
if (page.chapterId != previousChapterId) { if (page.chapterId != previousChapterId) {
chaptersLoader.chapters[page.chapterId]?.let { chaptersLoader.peekChapter(page.chapterId)?.let {
add(ListHeader(it.name, 0, null)) add(ListHeader(it.name, 0, null))
} }
previousChapterId = page.chapterId previousChapterId = page.chapterId

@ -18,3 +18,6 @@ scat
тентакли тентакли
футанари футанари
инцест инцест
boys' love
girls' love
bdsm

Loading…
Cancel
Save