mirror of
https://github.com/mihonapp/mihon.git
synced 2026-10-16 13:48:09 -07:00
Improve performance across screens (#4071)
* Keep screens loaded while they're hidden and rebuild the library faster * Count chapters in SQL for the migration list * Show the extension list sooner and check trust against loaded stores * Cancel the previous tracker search when searching again * Write reader progress less often and keep its work off the main thread * Stop rebuilding the settings search index on every key * Watch updates for widgets only while one is placed The query re-ran on every chapter or manga write even with no widget on the home screen. * Observe download pages and limit how often their progress updates * Throttle the library update notification and write fetch data together
This commit is contained in:
@@ -225,11 +225,10 @@ class SyncChaptersWithSource(
|
||||
added = toAdd,
|
||||
updated = updatedChapters,
|
||||
)
|
||||
updateManga.awaitUpdateFetchInterval(manga, timeZone, now, fetchWindow)
|
||||
|
||||
// Set this manga as updated since chapters were changed
|
||||
// Note that last_update actually represents last time the chapter list changed at all
|
||||
updateManga.awaitUpdateLastUpdate(manga.id)
|
||||
updateManga.awaitUpdateFetchIntervalAndLastUpdate(manga, timeZone, now, fetchWindow)
|
||||
|
||||
val excludedScanlators = getExcludedScanlators.await(manga.id).toHashSet()
|
||||
|
||||
|
||||
@@ -30,11 +30,10 @@ class GetExtensionsByType(
|
||||
|
||||
val updates = (loadedUpdates + notLoadedUpdates).sortedWith(byName)
|
||||
|
||||
val installedSignatures = (_loaded + _notLoaded).groupBy({ it.pkgName }, { it.signatures })
|
||||
val available = _available
|
||||
.filter { extension ->
|
||||
(_loaded + _notLoaded).none {
|
||||
it.pkgName == extension.pkgName && extension.store.signingKey in it.signatures
|
||||
} &&
|
||||
installedSignatures[extension.pkgName].orEmpty().none { extension.store.signingKey in it } &&
|
||||
extension.contentWarning in enabledContentWarnings
|
||||
}
|
||||
.flatMap { ext ->
|
||||
|
||||
@@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import mihon.domain.extension.model.ExtensionStore
|
||||
import mihon.domain.extension.repository.ExtensionStoreRepository
|
||||
import tachiyomi.core.common.preference.getAndSet
|
||||
|
||||
@@ -18,8 +19,8 @@ class TrustExtension(
|
||||
private val preferences: SourcePreferences,
|
||||
) {
|
||||
|
||||
suspend fun isTrusted(pkgInfo: PackageInfo, fingerprints: List<String>): Boolean {
|
||||
val trustedFingerprints = repository.getAll().map { it.signingKey }.toHashSet()
|
||||
fun isTrusted(pkgInfo: PackageInfo, fingerprints: List<String>, stores: List<ExtensionStore>): Boolean {
|
||||
val trustedFingerprints = stores.mapTo(HashSet()) { it.signingKey }
|
||||
val key = "${pkgInfo.packageName}:${PackageInfoCompat.getLongVersionCode(pkgInfo)}:${fingerprints.last()}"
|
||||
return trustedFingerprints.any { fingerprints.contains(it) } || key in preferences.trustedExtensions.get()
|
||||
}
|
||||
|
||||
@@ -36,8 +36,20 @@ class UpdateManga(
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun awaitUpdateLastUpdate(mangaId: Long): Boolean {
|
||||
return mangaRepository.update(MangaUpdate(mangaId) { lastUpdate = Clock.System.now() })
|
||||
/**
|
||||
* Updates the fetch interval and marks the chapter list as changed in a single write.
|
||||
*/
|
||||
suspend fun awaitUpdateFetchIntervalAndLastUpdate(
|
||||
manga: Manga,
|
||||
timeZone: TimeZone,
|
||||
dateTime: LocalDateTime,
|
||||
window: ClosedRange<Instant>?,
|
||||
): Boolean {
|
||||
return mangaRepository.update(
|
||||
fetchInterval.withFetchInterval(manga, dateTime, timeZone, window).apply {
|
||||
lastUpdate = Clock.System.now()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun awaitUpdateCoverLastModified(mangaId: Long): Boolean {
|
||||
|
||||
+58
-51
@@ -30,6 +30,7 @@ import androidx.compose.runtime.NonRestartableComposable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
@@ -48,6 +49,7 @@ import cafe.adriel.voyager.navigator.currentOrThrow
|
||||
import eu.kanade.presentation.components.UpIcon
|
||||
import eu.kanade.presentation.more.settings.Preference
|
||||
import eu.kanade.presentation.util.Screen
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import mihon.icons.materialsymbols.MaterialSymbols
|
||||
import mihon.icons.materialsymbols.rounded.Close
|
||||
import tachiyomi.i18n.MR
|
||||
@@ -141,7 +143,7 @@ class SettingsSearchScreen : Screen() {
|
||||
},
|
||||
) { contentPadding ->
|
||||
SearchResult(
|
||||
searchKey = textFieldState.text.toString(),
|
||||
searchKey = { textFieldState.text.toString() },
|
||||
listState = listState,
|
||||
contentPadding = contentPadding,
|
||||
) { result ->
|
||||
@@ -154,64 +156,20 @@ class SettingsSearchScreen : Screen() {
|
||||
|
||||
@Composable
|
||||
private fun SearchResult(
|
||||
searchKey: String,
|
||||
searchKey: () -> String,
|
||||
modifier: Modifier = Modifier,
|
||||
listState: LazyListState = rememberLazyListState(),
|
||||
contentPadding: PaddingValues = PaddingValues(),
|
||||
onItemClick: (SearchResultItem) -> Unit,
|
||||
) {
|
||||
if (searchKey.isEmpty()) return
|
||||
|
||||
val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr
|
||||
|
||||
val index = getIndex()
|
||||
val result by produceState<List<SearchResultItem>?>(initialValue = null, searchKey) {
|
||||
value = index.asSequence()
|
||||
.flatMap { settingsData ->
|
||||
fun result(title: String, categoryTitle: String?) = SearchResultItem(
|
||||
route = settingsData.route,
|
||||
title = title,
|
||||
breadcrumbs = getLocalizedBreadcrumb(
|
||||
path = settingsData.title,
|
||||
node = categoryTitle,
|
||||
isLtr = isLtr,
|
||||
),
|
||||
highlightKey = title,
|
||||
)
|
||||
|
||||
fun Preference.PreferenceItem<*, *>.matches(): Boolean {
|
||||
// Don't show info preference
|
||||
if (this is Preference.PreferenceItem.InfoPreference) return false
|
||||
val inTitle = title.contains(searchKey, true)
|
||||
val inSummary = subtitle?.contains(searchKey, true) ?: false
|
||||
return inTitle || inSummary
|
||||
}
|
||||
|
||||
settingsData.contents.asSequence()
|
||||
// Only search from enabled prefs and one with valid title
|
||||
.filter { it.visible && it.title.isNotBlank() }
|
||||
.flatMap { p ->
|
||||
when (p) {
|
||||
is Preference.PreferenceGroup -> {
|
||||
// A matching heading leads to the group itself, not to one of its items
|
||||
val heading = if (p.title.contains(searchKey, true)) {
|
||||
sequenceOf(result(p.title, null))
|
||||
} else {
|
||||
emptySequence()
|
||||
}
|
||||
val items = p.preferenceItems.asSequence()
|
||||
.filter { it.visible && it.title.isNotBlank() && it.matches() }
|
||||
.map { result(it.title, p.title) }
|
||||
heading + items
|
||||
}
|
||||
is Preference.PreferenceItem<*, *> -> {
|
||||
if (p.matches()) sequenceOf(result(p.title, null)) else emptySequence()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.take(10) // Just take top 10 result for quicker result
|
||||
.toList()
|
||||
// The query is read here rather than in composition, so typing doesn't rebuild the index on every key
|
||||
val result by produceState<List<SearchResultItem>?>(initialValue = null, index) {
|
||||
snapshotFlow(searchKey).collectLatest { searchKey ->
|
||||
value = if (searchKey.isEmpty()) null else search(index, searchKey, isLtr)
|
||||
}
|
||||
}
|
||||
|
||||
Crossfade(
|
||||
@@ -262,6 +220,55 @@ private fun SearchResult(
|
||||
}
|
||||
}
|
||||
|
||||
private fun search(index: List<SettingsData>, searchKey: String, isLtr: Boolean): List<SearchResultItem> {
|
||||
return index.asSequence()
|
||||
.flatMap { settingsData ->
|
||||
fun result(title: String, categoryTitle: String?) = SearchResultItem(
|
||||
route = settingsData.route,
|
||||
title = title,
|
||||
breadcrumbs = getLocalizedBreadcrumb(
|
||||
path = settingsData.title,
|
||||
node = categoryTitle,
|
||||
isLtr = isLtr,
|
||||
),
|
||||
highlightKey = title,
|
||||
)
|
||||
|
||||
fun Preference.PreferenceItem<*, *>.matches(): Boolean {
|
||||
// Don't show info preference
|
||||
if (this is Preference.PreferenceItem.InfoPreference) return false
|
||||
val inTitle = title.contains(searchKey, true)
|
||||
val inSummary = subtitle?.contains(searchKey, true) ?: false
|
||||
return inTitle || inSummary
|
||||
}
|
||||
|
||||
settingsData.contents.asSequence()
|
||||
// Only search from enabled prefs and one with valid title
|
||||
.filter { it.visible && it.title.isNotBlank() }
|
||||
.flatMap { p ->
|
||||
when (p) {
|
||||
is Preference.PreferenceGroup -> {
|
||||
// A matching heading leads to the group itself, not to one of its items
|
||||
val heading = if (p.title.contains(searchKey, true)) {
|
||||
sequenceOf(result(p.title, null))
|
||||
} else {
|
||||
emptySequence()
|
||||
}
|
||||
val items = p.preferenceItems.asSequence()
|
||||
.filter { it.visible && it.title.isNotBlank() && it.matches() }
|
||||
.map { result(it.title, p.title) }
|
||||
heading + items
|
||||
}
|
||||
is Preference.PreferenceItem<*, *> -> {
|
||||
if (p.matches()) sequenceOf(result(p.title, null)) else emptySequence()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.take(10) // Just take top 10 result for quicker result
|
||||
.toList()
|
||||
}
|
||||
|
||||
@Composable
|
||||
@NonRestartableComposable
|
||||
private fun getIndex() = settingScreens
|
||||
|
||||
@@ -211,6 +211,27 @@ class DownloadCache(
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the amount of downloaded chapters for each of the given manga, keyed by manga id.
|
||||
*
|
||||
* @param manga the manga to check.
|
||||
*/
|
||||
fun getDownloadCounts(manga: List<Manga>): Map<Long, Int> {
|
||||
renewCache()
|
||||
|
||||
val sourceDirs = rootDownloadsDir.sourceDirs
|
||||
return manga.associate {
|
||||
val mangaDirs = sourceDirs[it.source]?.mangaDirs
|
||||
// Building the directory name is the costly part, so it's skipped for sources without downloads
|
||||
val mangaDir = if (mangaDirs.isNullOrEmpty()) {
|
||||
null
|
||||
} else {
|
||||
mangaDirs[provider.getMangaDirName(it.title)]
|
||||
}
|
||||
it.id to (mangaDir?.chapterDirs?.size ?: 0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a chapter that has just been download to this cache.
|
||||
*
|
||||
|
||||
@@ -246,6 +246,15 @@ class DownloadManager(
|
||||
return cache.getDownloadCount(manga)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the amount of downloaded chapters for each of the given manga, keyed by manga id.
|
||||
*
|
||||
* @param manga the manga to check.
|
||||
*/
|
||||
fun getDownloadCounts(manga: List<Manga>): Map<Long, Int> {
|
||||
return cache.getDownloadCounts(manga)
|
||||
}
|
||||
|
||||
suspend fun cancelQueuedDownloads(downloads: List<Download>) {
|
||||
downloader.dequeue(downloads.map { it.chapter })
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.data.download
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.graphics.BitmapFactory
|
||||
import android.os.SystemClock
|
||||
import androidx.core.app.NotificationCompat
|
||||
import dev.zacsweers.metro.Inject
|
||||
import eu.kanade.tachiyomi.R
|
||||
@@ -62,14 +63,23 @@ class DownloadNotifier(
|
||||
context.cancelNotification(Notifications.ID_DOWNLOAD_CHAPTER_PAUSED)
|
||||
}
|
||||
|
||||
private var lastProgressNotificationTime = 0L
|
||||
|
||||
/**
|
||||
* Called when download progress changes.
|
||||
*
|
||||
* Called for every downloaded page from parallel downloads, so it shares the builder under a lock and skips
|
||||
* updates that come faster than the system would show them anyway.
|
||||
*
|
||||
* @param download download object containing download information.
|
||||
*/
|
||||
// Pages report their progress from several threads, and they share the builder
|
||||
@Synchronized
|
||||
fun onProgressChange(download: Download) {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val isComplete = download.downloadedImages == download.pages?.size
|
||||
if (!isComplete && now - lastProgressNotificationTime < PROGRESS_NOTIFICATION_INTERVAL_MS) return
|
||||
lastProgressNotificationTime = now
|
||||
|
||||
with(progressNotificationBuilder) {
|
||||
if (!isDownloading) {
|
||||
context.cancelNotification(Notifications.ID_DOWNLOAD_CHAPTER_PAUSED)
|
||||
@@ -156,6 +166,7 @@ class DownloadNotifier(
|
||||
/**
|
||||
* Resets the state once downloads are completed.
|
||||
*/
|
||||
@Synchronized
|
||||
fun onComplete() {
|
||||
dismissProgress()
|
||||
|
||||
@@ -229,3 +240,5 @@ class DownloadNotifier(
|
||||
isDownloading = false
|
||||
}
|
||||
}
|
||||
|
||||
private const val PROGRESS_NOTIFICATION_INTERVAL_MS = 500L
|
||||
|
||||
@@ -19,11 +19,14 @@ data class Download(
|
||||
val chapter: Chapter,
|
||||
) {
|
||||
@Transient
|
||||
private val pagesFlow = MutableStateFlow<List<Page>?>(null)
|
||||
private val _pagesFlow = MutableStateFlow<List<Page>?>(null)
|
||||
|
||||
@Transient
|
||||
val pagesFlow = _pagesFlow.asStateFlow()
|
||||
var pages: List<Page>?
|
||||
get() = pagesFlow.value
|
||||
get() = _pagesFlow.value
|
||||
set(pages) {
|
||||
pagesFlow.value = pages
|
||||
_pagesFlow.value = pages
|
||||
}
|
||||
|
||||
val totalProgress: Int
|
||||
@@ -44,7 +47,7 @@ data class Download(
|
||||
}
|
||||
|
||||
@Transient
|
||||
val progressFlow = pagesFlow
|
||||
val progressFlow = _pagesFlow
|
||||
.flatMapLatest { pages ->
|
||||
if (pages == null) {
|
||||
flowOf(0)
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.os.SystemClock
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import coil3.asDrawable
|
||||
@@ -85,14 +86,24 @@ class LibraryUpdateNotifier(
|
||||
}
|
||||
}
|
||||
|
||||
private var lastProgressNotificationTime = 0L
|
||||
|
||||
/**
|
||||
* Shows the notification containing the currently updating manga and the progress.
|
||||
*
|
||||
* Called from several update coroutines at once, so it shares the builder under a lock and skips updates that
|
||||
* come faster than the system would show them anyway.
|
||||
*
|
||||
* @param manga the manga that are being updated.
|
||||
* @param current the current progress.
|
||||
* @param total the total progress.
|
||||
*/
|
||||
@Synchronized
|
||||
fun showProgressNotification(manga: List<Manga>, current: Int, total: Int) {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
if (current < total && now - lastProgressNotificationTime < NOTIF_PROGRESS_INTERVAL_MS) return
|
||||
lastProgressNotificationTime = now
|
||||
|
||||
progressNotificationBuilder
|
||||
.setContentTitle(
|
||||
context.stringResource(
|
||||
@@ -388,3 +399,4 @@ private const val NOTIF_MAX_CHAPTERS = 5
|
||||
private const val NOTIF_TITLE_MAX_LEN = 45
|
||||
private const val NOTIF_ICON_SIZE = 192
|
||||
private const val MANGA_PER_SOURCE_QUEUE_WARNING_THRESHOLD = 60
|
||||
private const val NOTIF_PROGRESS_INTERVAL_MS = 500L
|
||||
|
||||
@@ -367,7 +367,7 @@ internal object ExtensionLoader {
|
||||
if (signatures.isEmpty()) {
|
||||
logcat(LogPriority.WARN) { "Package $pkgName isn't signed" }
|
||||
return notLoaded(Extension.NotLoaded.Reason.Unsigned, libVersion)
|
||||
} else if (!trustExtension.isTrusted(pkgInfo, signatures)) {
|
||||
} else if (!trustExtension.isTrusted(pkgInfo, signatures, stores)) {
|
||||
logcat(LogPriority.WARN) { "Extension $pkgName isn't trusted" }
|
||||
return notLoaded(Extension.NotLoaded.Reason.Untrusted(signatures.last()), libVersion)
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ import kotlinx.coroutines.flow.takeWhile
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import tachiyomi.i18n.MR
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@Inject
|
||||
@@ -69,7 +70,8 @@ class ExtensionsViewModel(
|
||||
@Suppress("LocalVariableName")
|
||||
private val items = combine(
|
||||
searchQuery
|
||||
.debounce(0.25.seconds)
|
||||
// No query is the common case on every open, so don't delay it
|
||||
.debounce { if (it.isNullOrEmpty()) Duration.ZERO else 0.25.seconds }
|
||||
.map { searchQueryPredicate(it ?: "") },
|
||||
currentDownloads,
|
||||
getExtensions.subscribe(),
|
||||
|
||||
@@ -14,7 +14,6 @@ import eu.kanade.tachiyomi.databinding.DownloadListBinding
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.WhileSubscribed
|
||||
@@ -22,6 +21,8 @@ import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
@@ -213,11 +214,8 @@ class DownloadQueueViewModel(
|
||||
*/
|
||||
private fun launchProgressJob(download: Download) {
|
||||
val job = viewModelScope.launch {
|
||||
while (download.pages == null) {
|
||||
delay(50.milliseconds)
|
||||
}
|
||||
|
||||
val progressFlows = download.pages!!.map(Page::progressFlow)
|
||||
val pages = download.pagesFlow.filterNotNull().first()
|
||||
val progressFlows = pages.map(Page::progressFlow)
|
||||
combine(progressFlows, Array<Int>::sum)
|
||||
.distinctUntilChanged()
|
||||
.debounce(50.milliseconds)
|
||||
|
||||
@@ -21,7 +21,6 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.WhileSubscribed
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
@@ -32,10 +31,12 @@ import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.plus
|
||||
import logcat.LogPriority
|
||||
import mihon.core.metro.AppCoroutineScope
|
||||
import tachiyomi.core.common.preference.CheckboxState
|
||||
import tachiyomi.core.common.preference.mapAsCheckboxState
|
||||
import tachiyomi.core.common.util.lang.SubscriberGate
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
import tachiyomi.domain.category.interactor.GetCategories
|
||||
import tachiyomi.domain.category.interactor.SetMangaCategories
|
||||
@@ -71,6 +72,8 @@ class HistoryViewModel(
|
||||
private val sourceManager: SourceManager,
|
||||
) : ViewModel() {
|
||||
|
||||
private val subscriberGate = SubscriberGate()
|
||||
|
||||
val snackbarHostState: SnackbarHostState = SnackbarHostState()
|
||||
|
||||
private val _events: Channel<Event> = Channel(Channel.UNLIMITED)
|
||||
@@ -91,7 +94,7 @@ class HistoryViewModel(
|
||||
.map { it.toHistoryUiModels() }
|
||||
.flowOn(Dispatchers.Default)
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), emptyList())
|
||||
.stateIn(viewModelScope + subscriberGate, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
val state: StateFlow<State> = combine(
|
||||
searchQuery,
|
||||
@@ -100,7 +103,7 @@ class HistoryViewModel(
|
||||
) { searchQuery, history, dialog ->
|
||||
State(searchQuery = searchQuery, list = history, dialog = dialog)
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), State())
|
||||
.stateIn(viewModelScope + subscriberGate, subscriberGate.whileSubscribed(5.seconds), State())
|
||||
|
||||
private fun List<HistoryWithRelations>.toHistoryUiModels(): List<HistoryUiModel> {
|
||||
return map { HistoryUiModel.Item(it) }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package eu.kanade.tachiyomi.ui.library
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.ReportDrawnWhen
|
||||
import androidx.compose.animation.graphics.res.animatedVectorResource
|
||||
import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter
|
||||
import androidx.compose.animation.graphics.vector.AnimatedImageVector
|
||||
@@ -270,6 +271,8 @@ data object LibraryTab : Tab {
|
||||
(context as? MainActivity)?.ready = true
|
||||
}
|
||||
}
|
||||
// Lets startup be measured up to the library, not just its loading screen
|
||||
ReportDrawnWhen { !state.isLoading }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
launch { queryEvent.receiveAsFlow().collect(viewModel::search) }
|
||||
|
||||
@@ -33,7 +33,6 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.WhileSubscribed
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
@@ -44,6 +43,7 @@ import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.plus
|
||||
import kotlinx.coroutines.withContext
|
||||
import mihon.core.common.utils.mutate
|
||||
import mihon.core.metro.AppCoroutineScope
|
||||
@@ -51,7 +51,9 @@ import mihon.domain.library.model.search.QueryNode
|
||||
import mihon.feature.library.matches
|
||||
import tachiyomi.core.common.preference.CheckboxState
|
||||
import tachiyomi.core.common.preference.TriState
|
||||
import tachiyomi.core.common.util.lang.SubscriberGate
|
||||
import tachiyomi.core.common.util.lang.compareToWithCollator
|
||||
import tachiyomi.core.common.util.lang.pausedWhileUnsubscribed
|
||||
import tachiyomi.domain.category.interactor.GetCategories
|
||||
import tachiyomi.domain.category.interactor.SetMangaCategories
|
||||
import tachiyomi.domain.category.model.Category
|
||||
@@ -98,6 +100,8 @@ class LibraryViewModel(
|
||||
private val trackerManager: TrackerManager,
|
||||
) : ViewModel() {
|
||||
|
||||
private val subscriberGate = SubscriberGate()
|
||||
|
||||
private val searchQuery = MutableStateFlow<String?>(null)
|
||||
|
||||
private val selection = MutableStateFlow(emptySet</* Manga */ Long>())
|
||||
@@ -140,7 +144,7 @@ class LibraryViewModel(
|
||||
.flatMapLatest { needsTracks -> if (needsTracks) getTracksPerManga.subscribe() else flowOf(emptyMap()) }
|
||||
|
||||
// Shared separately so search, selection and dialog changes still reach [state] before the
|
||||
// first query result, and so returning to the tab doesn't flash empty while it restarts.
|
||||
// first query result
|
||||
private val library = combine(
|
||||
searchQuery.debounce(0.25.seconds),
|
||||
getCategories.subscribe(),
|
||||
@@ -179,7 +183,7 @@ class LibraryViewModel(
|
||||
)
|
||||
}
|
||||
.flowOn(Dispatchers.Default)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), null)
|
||||
.stateIn(viewModelScope + subscriberGate, SharingStarted.Eagerly, null)
|
||||
|
||||
val state: StateFlow<State> = combine(
|
||||
library,
|
||||
@@ -200,7 +204,7 @@ class LibraryViewModel(
|
||||
groupedFavorites = library?.groupedFavorites.orEmpty(),
|
||||
)
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), State())
|
||||
.stateIn(viewModelScope + subscriberGate, subscriberGate.whileSubscribed(5.seconds), State())
|
||||
|
||||
private data class DisplayPreferences(
|
||||
val showCategoryTabs: Boolean,
|
||||
@@ -302,10 +306,9 @@ class LibraryViewModel(
|
||||
trackMap: Map<Long, List<Track>>,
|
||||
loggedInTrackerIds: Set<Long>,
|
||||
): Map<Category, List</* LibraryItem */ Long>> {
|
||||
// The collator already ignores case, and lowercasing on every comparison is slow on large libraries
|
||||
val sortAlphabetically: (LibraryItem, LibraryItem) -> Int = { manga1, manga2 ->
|
||||
val title1 = manga1.libraryManga.manga.title.lowercase()
|
||||
val title2 = manga2.libraryManga.manga.title.lowercase()
|
||||
title1.compareToWithCollator(title2)
|
||||
manga1.libraryManga.manga.title.compareToWithCollator(manga2.libraryManga.manga.title)
|
||||
}
|
||||
|
||||
val defaultTrackerScoreSortValue = -1.0
|
||||
@@ -415,14 +418,15 @@ class LibraryViewModel(
|
||||
return combine(
|
||||
getLibraryManga.subscribe(),
|
||||
getLibraryItemPreferencesFlow(),
|
||||
downloadCache.changes,
|
||||
downloadCache.changes.pausedWhileUnsubscribed(),
|
||||
) { libraryManga, preferences, _ ->
|
||||
val sources = libraryManga
|
||||
.mapTo(mutableSetOf()) { it.manga.source }
|
||||
.associateWith { sourceManager.getOrStub(it) }
|
||||
val downloadCounts = downloadManager.getDownloadCounts(libraryManga.map { it.manga })
|
||||
libraryManga.map { manga ->
|
||||
val source = sources.getValue(manga.manga.source)
|
||||
val downloadCount = downloadManager.getDownloadCount(manga.manga)
|
||||
val downloadCount = downloadCounts.getValue(manga.id)
|
||||
LibraryItem(
|
||||
libraryManga = manga,
|
||||
downloadCount = downloadCount,
|
||||
|
||||
@@ -51,7 +51,6 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.WhileSubscribed
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
@@ -65,9 +64,11 @@ import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.sample
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.plus
|
||||
import kotlinx.coroutines.withContext
|
||||
import logcat.LogPriority
|
||||
import mihon.core.metro.AppCoroutineScope
|
||||
@@ -77,6 +78,8 @@ import tachiyomi.core.common.i18n.stringResource
|
||||
import tachiyomi.core.common.preference.CheckboxState
|
||||
import tachiyomi.core.common.preference.TriState
|
||||
import tachiyomi.core.common.preference.mapAsCheckboxState
|
||||
import tachiyomi.core.common.util.lang.SubscriberGate
|
||||
import tachiyomi.core.common.util.lang.pausedWhileUnsubscribed
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
import tachiyomi.domain.category.interactor.GetCategories
|
||||
import tachiyomi.domain.category.interactor.SetMangaCategories
|
||||
@@ -100,6 +103,7 @@ import tachiyomi.domain.track.interactor.GetTracks
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.source.local.isLocal
|
||||
import kotlin.math.floor
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@AssistedInject
|
||||
@@ -136,6 +140,8 @@ class MangaViewModel(
|
||||
private val coverCache: CoverCache,
|
||||
) : ViewModel() {
|
||||
|
||||
private val subscriberGate = SubscriberGate()
|
||||
|
||||
@AssistedFactory
|
||||
@ManualViewModelAssistedFactoryKey
|
||||
@ContributesIntoMap(AppScope::class)
|
||||
@@ -206,8 +212,8 @@ class MangaViewModel(
|
||||
emitAll(getMangaAndChapters.subscribe(mangaId, applyScanlatorFilter = true))
|
||||
}
|
||||
.distinctUntilChanged(),
|
||||
downloadCache.changes,
|
||||
downloadManager.queueState,
|
||||
downloadCache.changes.pausedWhileUnsubscribed(),
|
||||
downloadManager.queueState.pausedWhileUnsubscribed(),
|
||||
) { (manga, chapters), _, _ ->
|
||||
MangaAndChapters(
|
||||
manga = manga,
|
||||
@@ -242,7 +248,7 @@ class MangaViewModel(
|
||||
mangaAndChapters,
|
||||
scanlators,
|
||||
trackers,
|
||||
combine(selectedChapterIds, queuedDownloadStates, ::Pair),
|
||||
combine(selectedChapterIds, queuedDownloadStates.pausedWhileUnsubscribed(), ::Pair),
|
||||
combine(dialog, isRefreshingData, ::Pair),
|
||||
) {
|
||||
mangaAndChapters,
|
||||
@@ -281,12 +287,16 @@ class MangaViewModel(
|
||||
it.isAnySelected
|
||||
}
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), State.Loading)
|
||||
.stateIn(viewModelScope + subscriberGate, subscriberGate.whileSubscribed(5.seconds), State.Loading)
|
||||
|
||||
init {
|
||||
// Progress ticks often, and each one copies the map of download states
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
merge(downloadManager.statusFlow(), downloadManager.progressFlow())
|
||||
merge(
|
||||
downloadManager.statusFlow(),
|
||||
// Each tick rebuilds the shown chapter items, so cap how often that happens
|
||||
downloadManager.progressFlow().sample(200.milliseconds),
|
||||
)
|
||||
.filter { it.manga.id == mangaId }
|
||||
.catch { logcat(LogPriority.ERROR, it) }
|
||||
.collect(::updateDownloadState)
|
||||
|
||||
@@ -59,8 +59,10 @@ import eu.kanade.tachiyomi.util.lang.convertEpochMillisZone
|
||||
import eu.kanade.tachiyomi.util.system.copyToClipboard
|
||||
import eu.kanade.tachiyomi.util.system.openInBrowser
|
||||
import eu.kanade.tachiyomi.util.system.toast
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
@@ -777,6 +779,8 @@ data class TrackerSearchScreen(
|
||||
|
||||
private val tracker = trackerManager.get(trackerId)!!
|
||||
|
||||
private var searchJob: Job? = null
|
||||
|
||||
val supportsPrivateTracking = tracker.supportsPrivateTracking
|
||||
|
||||
init {
|
||||
@@ -787,7 +791,8 @@ data class TrackerSearchScreen(
|
||||
}
|
||||
|
||||
fun trackingSearch(query: String) {
|
||||
viewModelScope.launch {
|
||||
searchJob?.cancel()
|
||||
searchJob = viewModelScope.launch {
|
||||
// To show loading state
|
||||
state.update { it.copy(queryResult = null, selected = null) }
|
||||
|
||||
@@ -796,6 +801,7 @@ data class TrackerSearchScreen(
|
||||
val results = tracker.search(query)
|
||||
Result.success(results)
|
||||
} catch (e: Throwable) {
|
||||
if (e is CancellationException) throw e
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
@@ -95,6 +96,7 @@ import tachiyomi.domain.source.service.SourceManager
|
||||
import tachiyomi.source.local.image.LocalCoverManager
|
||||
import tachiyomi.source.local.isLocal
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
@@ -197,6 +199,11 @@ class ReaderViewModel(
|
||||
// Serializes the progress writes, which outlive the reader, so they are applied in order
|
||||
private val progressWrites = Mutex()
|
||||
|
||||
// Set on Main, flushed from the chapter load too
|
||||
@Volatile
|
||||
private var pendingProgressChapter: ReaderChapter? = null
|
||||
private var pendingProgressSave: Job? = null
|
||||
|
||||
private var chapterToDownload: Download? = null
|
||||
|
||||
private var unfilteredChapterList: List<Chapter>? = null
|
||||
@@ -540,10 +547,8 @@ class ReaderViewModel(
|
||||
val selectedChapter = page.chapter
|
||||
val pages = selectedChapter.pages ?: return
|
||||
|
||||
// Save last page read and mark as read if needed. The database calls are main-safe.
|
||||
appScope.launch(Dispatchers.Main.immediate) {
|
||||
updateChapterProgress(selectedChapter, page)
|
||||
}
|
||||
// Save last page read and mark as read if needed
|
||||
updateChapterProgress(selectedChapter, page)
|
||||
|
||||
if (selectedChapter != getCurrentChapter()) {
|
||||
logcat { "Setting ${selectedChapter.chapter.url} as active" }
|
||||
@@ -639,8 +644,11 @@ class ReaderViewModel(
|
||||
/**
|
||||
* Saves the chapter progress (last read page and whether it's read)
|
||||
* if incognito mode isn't on.
|
||||
*
|
||||
* Completing a chapter is saved right away. Other page changes are saved once the reader settles on a page, or
|
||||
* when [saveChapterProgress] is called, so flipping through pages doesn't write to the database on every page.
|
||||
*/
|
||||
private suspend fun updateChapterProgress(readerChapter: ReaderChapter, page: Page) {
|
||||
private fun updateChapterProgress(readerChapter: ReaderChapter, page: Page) {
|
||||
val pageIndex = page.index
|
||||
|
||||
mutableState.update {
|
||||
@@ -649,22 +657,49 @@ class ReaderViewModel(
|
||||
readerChapter.requestedPage = pageIndex
|
||||
chapterPageIndex = pageIndex
|
||||
|
||||
if (!incognitoMode && page.status !is Page.State.Error) {
|
||||
val read = readerChapter.chapter.read || readerChapter.pages?.lastIndex == pageIndex
|
||||
readerChapter.update { it.copy(lastPageRead = pageIndex, read = read) }
|
||||
val isLastPage = readerChapter.pages?.lastIndex == pageIndex
|
||||
if (incognitoMode || page.status is Page.State.Error) return
|
||||
|
||||
// Taken before the first suspension, and the mutex is fair, so the writes land in the order the pages
|
||||
// were selected even when one of them suspends longer
|
||||
val read = readerChapter.chapter.read || readerChapter.pages?.lastIndex == pageIndex
|
||||
// A page change in another chapter would replace that chapter's pending save
|
||||
if (pendingProgressChapter != readerChapter) saveChapterProgress()
|
||||
readerChapter.update { it.copy(lastPageRead = pageIndex, read = read) }
|
||||
pendingProgressSave?.cancel()
|
||||
|
||||
if (readerChapter.pages?.lastIndex == pageIndex) {
|
||||
pendingProgressChapter = null
|
||||
writeChapterProgress(readerChapter, onComplete = true)
|
||||
} else {
|
||||
pendingProgressChapter = readerChapter
|
||||
pendingProgressSave = viewModelScope.launch {
|
||||
delay(PROGRESS_SAVE_DELAY)
|
||||
saveChapterProgress()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the progress of the chapter whose page change hasn't been saved yet.
|
||||
*/
|
||||
private fun saveChapterProgress() {
|
||||
val readerChapter = pendingProgressChapter ?: return
|
||||
pendingProgressChapter = null
|
||||
writeChapterProgress(readerChapter, onComplete = false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the progress of [readerChapter] on the app scope, so it's saved even when the reader is closing.
|
||||
*/
|
||||
private fun writeChapterProgress(readerChapter: ReaderChapter, onComplete: Boolean) {
|
||||
// Immediate and through the mutex, which is fair, so the writes land in the order they were made
|
||||
appScope.launch(Dispatchers.Main.immediate) {
|
||||
progressWrites.withLock {
|
||||
if (isLastPage) {
|
||||
updateChapterProgressOnComplete(readerChapter)
|
||||
}
|
||||
if (onComplete) updateChapterProgressOnComplete(readerChapter)
|
||||
|
||||
val chapter = readerChapter.chapter
|
||||
updateChapter.await(
|
||||
ChapterUpdate(readerChapter.chapter.id) {
|
||||
this.read = read
|
||||
lastPageRead = pageIndex
|
||||
ChapterUpdate(chapter.id) {
|
||||
read = chapter.read
|
||||
lastPageRead = chapter.lastPageRead
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -710,6 +745,7 @@ class ReaderViewModel(
|
||||
* Saves the chapter last read history if incognito mode isn't on.
|
||||
*/
|
||||
private suspend fun updateHistory() {
|
||||
saveChapterProgress()
|
||||
getCurrentChapter()?.let { readerChapter ->
|
||||
if (incognitoMode) return@let
|
||||
|
||||
@@ -1109,3 +1145,5 @@ class ReaderViewModel(
|
||||
data class CopyImage(val uri: Uri) : Event
|
||||
}
|
||||
}
|
||||
|
||||
private val PROGRESS_SAVE_DELAY = 2.seconds
|
||||
|
||||
@@ -31,7 +31,6 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.WhileSubscribed
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
@@ -40,15 +39,19 @@ import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.sample
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.plus
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.minus
|
||||
import logcat.LogPriority
|
||||
import mihon.core.metro.AppCoroutineScope
|
||||
import tachiyomi.core.common.preference.TriState
|
||||
import tachiyomi.core.common.util.lang.SubscriberGate
|
||||
import tachiyomi.core.common.util.lang.pausedWhileUnsubscribed
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
import tachiyomi.domain.chapter.interactor.GetChapter
|
||||
import tachiyomi.domain.chapter.interactor.UpdateChapter
|
||||
@@ -61,6 +64,7 @@ import tachiyomi.domain.updates.interactor.GetUpdates
|
||||
import tachiyomi.domain.updates.model.UpdatesWithRelations
|
||||
import tachiyomi.domain.updates.service.UpdatesPreferences
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@Inject
|
||||
@@ -81,6 +85,8 @@ class UpdatesViewModel(
|
||||
private val updatesPreferences: UpdatesPreferences,
|
||||
) : ViewModel() {
|
||||
|
||||
private val subscriberGate = SubscriberGate()
|
||||
|
||||
val snackbarHostState: SnackbarHostState = SnackbarHostState()
|
||||
|
||||
private val _events: Channel<Event> = Channel(Int.MAX_VALUE)
|
||||
@@ -105,7 +111,8 @@ class UpdatesViewModel(
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
merge(downloadManager.statusFlow(), downloadManager.progressFlow())
|
||||
// Each progress tick rebuilds every row of the list, so cap how often that happens
|
||||
merge(downloadManager.statusFlow(), downloadManager.progressFlow().sample(200.milliseconds))
|
||||
.catch { logcat(LogPriority.ERROR, it) }
|
||||
.collect(this@UpdatesViewModel::updateDownloadState)
|
||||
}
|
||||
@@ -157,8 +164,8 @@ class UpdatesViewModel(
|
||||
excludedCategories = it.filterExcludedCategories,
|
||||
).distinctUntilChanged()
|
||||
},
|
||||
downloadCache.changes,
|
||||
downloadManager.queueState,
|
||||
downloadCache.changes.pausedWhileUnsubscribed(),
|
||||
downloadManager.queueState.pausedWhileUnsubscribed(),
|
||||
// needed for Kotlin filters (downloaded)
|
||||
getUpdatesItemPreferenceFlow().distinctUntilChanged { old, new ->
|
||||
old.filterDownloaded == new.filterDownloaded
|
||||
@@ -169,12 +176,12 @@ class UpdatesViewModel(
|
||||
.applyFilters(itemPreferences)
|
||||
}
|
||||
.flowOn(Dispatchers.Default)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), null)
|
||||
.stateIn(viewModelScope + subscriberGate, SharingStarted.Eagerly, null)
|
||||
|
||||
val state: StateFlow<State> = combine(
|
||||
updateItems,
|
||||
selectedChapterIds,
|
||||
queuedDownloadStates,
|
||||
queuedDownloadStates.pausedWhileUnsubscribed(),
|
||||
dialog,
|
||||
hasActiveFilters,
|
||||
) { items, selectedIds, downloads, dialog, hasActiveFilters ->
|
||||
@@ -201,7 +208,7 @@ class UpdatesViewModel(
|
||||
)
|
||||
}
|
||||
.flowOn(Dispatchers.Default)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), State())
|
||||
.stateIn(viewModelScope + subscriberGate, subscriberGate.whileSubscribed(5.seconds), State())
|
||||
|
||||
private fun List<UpdatesItem>.applyFilters(
|
||||
preferences: ItemPreferences,
|
||||
|
||||
@@ -38,7 +38,7 @@ import mihon.feature.migration.list.models.MigratingManga
|
||||
import mihon.feature.migration.list.models.MigratingManga.SearchResult
|
||||
import mihon.feature.migration.list.search.SmartSourceSearchEngine
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
|
||||
import tachiyomi.domain.chapter.interactor.GetChapterSummaryByMangaId
|
||||
import tachiyomi.domain.manga.interactor.GetManga
|
||||
import tachiyomi.domain.manga.interactor.NetworkToLocalManga
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
@@ -52,7 +52,7 @@ class MigrationListViewModel(
|
||||
private val sourceManager: SourceManager,
|
||||
private val getManga: GetManga,
|
||||
private val networkToLocalManga: NetworkToLocalManga,
|
||||
private val getChaptersByMangaId: GetChaptersByMangaId,
|
||||
private val getChapterSummaryByMangaId: GetChapterSummaryByMangaId,
|
||||
private val migrateManga: MigrateMangaUseCase,
|
||||
private val updateMangaFromRemote: UpdateMangaFromRemote,
|
||||
) : ViewModel() {
|
||||
@@ -103,10 +103,10 @@ class MigrationListViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getChapterInfo(id: Long) = getChaptersByMangaId.await(id).let { chapters ->
|
||||
private suspend fun getChapterInfo(id: Long) = getChapterSummaryByMangaId.await(id).let { summary ->
|
||||
ChapterInfo(
|
||||
latestChapter = chapters.maxOfOrNull { it.chapterNumber },
|
||||
chapterCount = chapters.size,
|
||||
latestChapter = summary.latestChapterNumber,
|
||||
chapterCount = summary.chapterCount.toInt(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.WhileSubscribed
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
@@ -24,6 +23,7 @@ import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.plus
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.YearMonth
|
||||
@@ -31,6 +31,7 @@ import kotlinx.datetime.toLocalDateTime
|
||||
import kotlinx.datetime.yearMonth
|
||||
import mihon.domain.upcoming.interactor.GetUpcomingManga
|
||||
import tachiyomi.core.common.preference.getAndSet
|
||||
import tachiyomi.core.common.util.lang.SubscriberGate
|
||||
import tachiyomi.domain.category.interactor.GetCategories
|
||||
import tachiyomi.domain.category.model.Category
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
@@ -47,6 +48,8 @@ class UpcomingViewModel(
|
||||
val upcomingPreferences: UpcomingPreferences,
|
||||
) : ViewModel() {
|
||||
|
||||
private val subscriberGate = SubscriberGate()
|
||||
|
||||
val excludedCategories = upcomingPreferences.filterExcludedCategories
|
||||
val includedCategories = upcomingPreferences.filterIncludedCategories
|
||||
|
||||
@@ -80,7 +83,7 @@ class UpcomingViewModel(
|
||||
.distinctUntilChanged()
|
||||
.map { it.toUpcomingUIModels() }
|
||||
.flowOn(Dispatchers.Default)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), emptyList())
|
||||
.stateIn(viewModelScope + subscriberGate, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
val state: StateFlow<State> = combine(
|
||||
upcoming,
|
||||
@@ -98,8 +101,8 @@ class UpcomingViewModel(
|
||||
)
|
||||
}
|
||||
.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.WhileSubscribed(5.seconds),
|
||||
viewModelScope + subscriberGate,
|
||||
subscriberGate.whileSubscribed(5.seconds),
|
||||
State(selectedYearMonth = selectedYearMonth.value),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package tachiyomi.core.common.util.lang
|
||||
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingCommand
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.conflate
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlin.coroutines.AbstractCoroutineContextElement
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.time.Duration
|
||||
|
||||
/**
|
||||
* Keeps a view model's flows running while its screen is hidden, but holds back database and other frequent
|
||||
* updates until the screen is visible again, so returning to it shows the last state instead of re-running
|
||||
* everything. Flows collected without a gate in their context are unaffected.
|
||||
*/
|
||||
class SubscriberGate : AbstractCoroutineContextElement(SubscriberGate) {
|
||||
|
||||
companion object Key : CoroutineContext.Key<SubscriberGate>
|
||||
|
||||
private val open = MutableStateFlow(true)
|
||||
|
||||
fun whileSubscribed(stopTimeout: Duration): SharingStarted = object : SharingStarted {
|
||||
override fun command(subscriptionCount: StateFlow<Int>): Flow<SharingCommand> = flow {
|
||||
emit(SharingCommand.START)
|
||||
subscriptionCount
|
||||
.map { it > 0 }
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { subscribed ->
|
||||
if (!subscribed) delay(stopTimeout)
|
||||
open.value = subscribed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal suspend fun awaitOpen() {
|
||||
open.first { it }
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> Flow<T>.pausedWhileUnsubscribed(): Flow<T> = flow {
|
||||
val gate = currentCoroutineContext()[SubscriberGate]
|
||||
if (gate == null) {
|
||||
emitAll(this@pausedWhileUnsubscribed)
|
||||
} else {
|
||||
this@pausedWhileUnsubscribed.conflate().collect {
|
||||
gate.awaitOpen()
|
||||
emit(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -318,8 +318,16 @@ object ImageUtil {
|
||||
null
|
||||
}
|
||||
|
||||
if (image == null) return Color.WHITE.toDrawable()
|
||||
return try {
|
||||
chooseBackground(context, image)
|
||||
} finally {
|
||||
image.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
private fun chooseBackground(context: Context, image: Bitmap): Drawable {
|
||||
val whiteColor = Color.WHITE
|
||||
if (image == null) return whiteColor.toDrawable()
|
||||
if (image.width < 50 || image.height < 50) {
|
||||
return whiteColor.toDrawable()
|
||||
}
|
||||
|
||||
@@ -6,17 +6,18 @@ import app.cash.sqldelight.coroutines.mapToList
|
||||
import app.cash.sqldelight.coroutines.mapToOne
|
||||
import app.cash.sqldelight.coroutines.mapToOneOrNull
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import tachiyomi.core.common.util.lang.pausedWhileUnsubscribed
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.coroutines.EmptyCoroutineContext
|
||||
|
||||
fun <T : Any> Query<T>.subscribeToList(
|
||||
context: CoroutineContext = EmptyCoroutineContext,
|
||||
): Flow<List<T>> = asFlow().mapToList(context)
|
||||
): Flow<List<T>> = asFlow().pausedWhileUnsubscribed().mapToList(context)
|
||||
|
||||
fun <T : Any> Query<T>.subscribeToOne(
|
||||
context: CoroutineContext = EmptyCoroutineContext,
|
||||
): Flow<T> = asFlow().mapToOne(context)
|
||||
): Flow<T> = asFlow().pausedWhileUnsubscribed().mapToOne(context)
|
||||
|
||||
fun <T : Any> Query<T>.subscribeToOneOrNull(
|
||||
context: CoroutineContext = EmptyCoroutineContext,
|
||||
): Flow<T?> = asFlow().mapToOneOrNull(context)
|
||||
): Flow<T?> = asFlow().pausedWhileUnsubscribed().mapToOneOrNull(context)
|
||||
|
||||
@@ -14,6 +14,7 @@ import tachiyomi.data.Database
|
||||
import tachiyomi.data.subscribeToList
|
||||
import tachiyomi.domain.chapter.model.Chapter
|
||||
import tachiyomi.domain.chapter.model.ChapterRemoteUpdate
|
||||
import tachiyomi.domain.chapter.model.ChapterSummary
|
||||
import tachiyomi.domain.chapter.model.ChapterUpdate
|
||||
import tachiyomi.domain.chapter.repository.ChapterRepository
|
||||
import kotlin.time.Instant
|
||||
@@ -112,6 +113,14 @@ class ChapterRepositoryImpl(
|
||||
.awaitAsList()
|
||||
}
|
||||
|
||||
override suspend fun getChapterSummaryByMangaId(mangaId: Long): ChapterSummary {
|
||||
return database.chapterQueries
|
||||
.getChapterSummaryByMangaId(mangaId) { total, latestChapterNumber ->
|
||||
ChapterSummary(chapterCount = total, latestChapterNumber = latestChapterNumber)
|
||||
}
|
||||
.awaitAsOne()
|
||||
}
|
||||
|
||||
override suspend fun getScanlatorsByMangaId(mangaId: Long): List<String> {
|
||||
return database.chapterQueries
|
||||
.getScanlatorsByMangaId(mangaId) { it.orEmpty() }
|
||||
|
||||
@@ -48,6 +48,11 @@ AND (
|
||||
OR ES.scanlator IS NULL
|
||||
);
|
||||
|
||||
getChapterSummaryByMangaId:
|
||||
SELECT count(*) AS total, max(remote_chapter_number) AS latestChapterNumber
|
||||
FROM chapter
|
||||
WHERE manga_id = :mangaId;
|
||||
|
||||
getScanlatorsByMangaId:
|
||||
SELECT DISTINCT remote_scanlator
|
||||
FROM chapter
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package tachiyomi.domain.chapter.interactor
|
||||
|
||||
import dev.zacsweers.metro.Inject
|
||||
import tachiyomi.domain.chapter.model.ChapterSummary
|
||||
import tachiyomi.domain.chapter.repository.ChapterRepository
|
||||
|
||||
@Inject
|
||||
class GetChapterSummaryByMangaId(
|
||||
private val chapterRepository: ChapterRepository,
|
||||
) {
|
||||
|
||||
suspend fun await(mangaId: Long): ChapterSummary {
|
||||
return chapterRepository.getChapterSummaryByMangaId(mangaId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package tachiyomi.domain.chapter.model
|
||||
|
||||
data class ChapterSummary(
|
||||
val chapterCount: Long,
|
||||
val latestChapterNumber: Double?,
|
||||
)
|
||||
@@ -3,6 +3,7 @@ package tachiyomi.domain.chapter.repository
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import tachiyomi.domain.chapter.model.Chapter
|
||||
import tachiyomi.domain.chapter.model.ChapterRemoteUpdate
|
||||
import tachiyomi.domain.chapter.model.ChapterSummary
|
||||
import tachiyomi.domain.chapter.model.ChapterUpdate
|
||||
|
||||
interface ChapterRepository {
|
||||
@@ -19,6 +20,8 @@ interface ChapterRepository {
|
||||
|
||||
suspend fun getChapterByMangaId(mangaId: Long, applyScanlatorFilter: Boolean = false): List<Chapter>
|
||||
|
||||
suspend fun getChapterSummaryByMangaId(mangaId: Long): ChapterSummary
|
||||
|
||||
suspend fun getScanlatorsByMangaId(mangaId: Long): List<String>
|
||||
|
||||
fun getScanlatorsByMangaIdAsFlow(mangaId: Long): Flow<List<String>>
|
||||
|
||||
+11
@@ -1,9 +1,20 @@
|
||||
package tachiyomi.presentation.widget
|
||||
|
||||
import android.content.Context
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
|
||||
class UpdatesGridCoverScreenGlanceReceiver : GlanceAppWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget
|
||||
get() = UpdatesGridCoverScreenGlanceWidget()
|
||||
|
||||
override fun onEnabled(context: Context) {
|
||||
super.onEnabled(context)
|
||||
WidgetManager.onWidgetEnabled(glanceAppWidget)
|
||||
}
|
||||
|
||||
override fun onDisabled(context: Context) {
|
||||
super.onDisabled(context)
|
||||
WidgetManager.onWidgetDisabled(glanceAppWidget)
|
||||
}
|
||||
}
|
||||
|
||||
+11
@@ -1,5 +1,6 @@
|
||||
package tachiyomi.presentation.widget
|
||||
|
||||
import android.content.Context
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
|
||||
@@ -7,4 +8,14 @@ class UpdatesGridGlanceReceiver : GlanceAppWidgetReceiver() {
|
||||
|
||||
override val glanceAppWidget: GlanceAppWidget
|
||||
get() = UpdatesGridGlanceWidget()
|
||||
|
||||
override fun onEnabled(context: Context) {
|
||||
super.onEnabled(context)
|
||||
WidgetManager.onWidgetEnabled(glanceAppWidget)
|
||||
}
|
||||
|
||||
override fun onDisabled(context: Context) {
|
||||
super.onDisabled(context)
|
||||
WidgetManager.onWidgetDisabled(glanceAppWidget)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
package tachiyomi.presentation.widget
|
||||
|
||||
import android.content.Context
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import dev.zacsweers.metro.Inject
|
||||
import eu.kanade.tachiyomi.core.security.SecurityPreferences
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
import tachiyomi.domain.updates.interactor.GetUpdates
|
||||
@@ -21,21 +30,40 @@ class WidgetManager(
|
||||
private val securityPreferences: SecurityPreferences,
|
||||
) {
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
context(context: Context)
|
||||
fun init(scope: CoroutineScope) {
|
||||
combine(
|
||||
getUpdates.subscribe(read = false, after = BaseUpdatesGridGlanceWidget.DateLimit),
|
||||
securityPreferences.useAuthenticator.changes(),
|
||||
transform = { a, b -> a to b },
|
||||
)
|
||||
scope.launch(Dispatchers.Default) {
|
||||
val manager = GlanceAppWidgetManager(context)
|
||||
val placed = widgets.filter { manager.getGlanceIds(it.javaClass).isNotEmpty() }
|
||||
placedWidgets.update { it + placed.map { widget -> widget.javaClass } }
|
||||
}
|
||||
|
||||
// Only watch updates while a widget is on the home screen
|
||||
placedWidgets
|
||||
.map { it.isNotEmpty() }
|
||||
.distinctUntilChanged()
|
||||
.flatMapLatest { hasWidgets ->
|
||||
if (hasWidgets) {
|
||||
combine(
|
||||
getUpdates.subscribe(
|
||||
read = false,
|
||||
after = BaseUpdatesGridGlanceWidget.DateLimit,
|
||||
),
|
||||
securityPreferences.useAuthenticator.changes(),
|
||||
transform = { a, b -> a to b },
|
||||
)
|
||||
} else {
|
||||
emptyFlow()
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged { old, new ->
|
||||
old.second == new.second &&
|
||||
old.first.map { it.chapterId }.toSet() == new.first.map { it.chapterId }.toSet()
|
||||
}
|
||||
.onEach {
|
||||
try {
|
||||
UpdatesGridGlanceWidget().updateAll(context)
|
||||
UpdatesGridCoverScreenGlanceWidget().updateAll(context)
|
||||
widgets.forEach { it.updateAll(context) }
|
||||
} catch (e: Exception) {
|
||||
this.logcat(LogPriority.ERROR, e) { "Failed to update widget" }
|
||||
}
|
||||
@@ -43,4 +71,19 @@ class WidgetManager(
|
||||
.flowOn(Dispatchers.Default)
|
||||
.launchIn(scope)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val widgets: List<GlanceAppWidget>
|
||||
get() = listOf(UpdatesGridGlanceWidget(), UpdatesGridCoverScreenGlanceWidget())
|
||||
|
||||
private val placedWidgets = MutableStateFlow(emptySet<Class<out GlanceAppWidget>>())
|
||||
|
||||
internal fun onWidgetEnabled(widget: GlanceAppWidget) {
|
||||
placedWidgets.update { it + widget.javaClass }
|
||||
}
|
||||
|
||||
internal fun onWidgetDisabled(widget: GlanceAppWidget) {
|
||||
placedWidgets.update { it - widget.javaClass }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user