feat(reading-eta): share SeriesReadingEtaCalculator across reader and library
Extract a single ETA estimator, refresh library badges when history changes, and expose a history revision stream for reactive updates. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
b299a9ce81
commit
4fce1fa940
8 changed files with 417 additions and 288 deletions
|
|
@ -67,6 +67,7 @@ import tachiyomi.domain.chapter.interactor.UpdateChapter
|
|||
import tachiyomi.domain.chapter.repository.ChapterRepository
|
||||
import tachiyomi.domain.history.interactor.GetCrossMangaPageRateHistorySamples
|
||||
import tachiyomi.domain.history.interactor.GetHistory
|
||||
import tachiyomi.domain.history.interactor.GetHistoryRevision
|
||||
import tachiyomi.domain.history.interactor.GetNextChapters
|
||||
import tachiyomi.domain.history.interactor.GetReadDurationEntriesByMangaIds
|
||||
import tachiyomi.domain.history.interactor.GetTotalReadDuration
|
||||
|
|
@ -173,6 +174,7 @@ class DomainModule : InjektModule {
|
|||
|
||||
addSingletonFactory<HistoryRepository> { HistoryRepositoryImpl(get()) }
|
||||
addFactory { GetHistory(get()) }
|
||||
addFactory { GetHistoryRevision(get()) }
|
||||
addFactory { UpsertHistory(get()) }
|
||||
addFactory { RemoveHistory(get()) }
|
||||
addFactory { GetTotalReadDuration(get()) }
|
||||
|
|
|
|||
|
|
@ -98,6 +98,9 @@ import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
|
|||
import tachiyomi.domain.chapter.interactor.GetMergedChaptersByMangaId
|
||||
import tachiyomi.domain.chapter.model.Chapter
|
||||
import tachiyomi.domain.history.interactor.GetNextChapters
|
||||
import tachiyomi.domain.chapter.service.getChapterSort
|
||||
import tachiyomi.domain.history.interactor.GetCrossMangaPageRateHistorySamples
|
||||
import tachiyomi.domain.history.interactor.GetHistoryRevision
|
||||
import tachiyomi.domain.history.interactor.GetReadDurationEntriesByMangaIds
|
||||
import tachiyomi.domain.history.interactor.GetTotalReadDuration
|
||||
import tachiyomi.domain.library.model.LibraryDisplayMode
|
||||
|
|
@ -107,6 +110,7 @@ import tachiyomi.domain.library.model.LibrarySort
|
|||
import tachiyomi.domain.library.model.sort
|
||||
import tachiyomi.domain.library.service.LibraryPreferences
|
||||
import tachiyomi.domain.reading.ReadingEtaLimits
|
||||
import tachiyomi.domain.reading.SeriesReadingEtaCalculator
|
||||
import tachiyomi.domain.reading.service.ReadingEtaPreferences
|
||||
import tachiyomi.domain.manga.interactor.GetIdsOfFavoriteMangaWithMetadata
|
||||
import tachiyomi.domain.manga.interactor.GetLibraryManga
|
||||
|
|
@ -153,6 +157,8 @@ class LibraryScreenModel(
|
|||
private val sourcePreferences: SourcePreferences = Injekt.get(),
|
||||
private val getMergedMangaById: GetMergedMangaById = Injekt.get(),
|
||||
private val getReadDurationEntriesByMangaIds: GetReadDurationEntriesByMangaIds = Injekt.get(),
|
||||
private val getCrossMangaPageRateHistorySamples: GetCrossMangaPageRateHistorySamples = Injekt.get(),
|
||||
private val getHistoryRevision: GetHistoryRevision = Injekt.get(),
|
||||
private val getTotalReadDuration: GetTotalReadDuration = Injekt.get(),
|
||||
private val getTracks: GetTracks = Injekt.get(),
|
||||
private val getIdsOfFavoriteMangaWithMetadata: GetIdsOfFavoriteMangaWithMetadata = Injekt.get(),
|
||||
|
|
@ -447,64 +453,57 @@ class LibraryScreenModel(
|
|||
): Map<Long, Long?> {
|
||||
if (favorites.isEmpty()) return emptyMap()
|
||||
|
||||
val chapterDurationsByMangaId = favorites.associate { item ->
|
||||
item.id to getReadDurationEntriesByMangaIds.await(item.id).map { it.readDuration }
|
||||
}
|
||||
val globalAverageChapterDurationMs = calculateAverageChapterDurationMs(
|
||||
chapterDurationsByMangaId.values.flatten(),
|
||||
limits,
|
||||
)
|
||||
|
||||
return favorites.associate { item ->
|
||||
item.id to calculateEstimatedTimeLeftMs(
|
||||
item = item,
|
||||
chapterDurations = chapterDurationsByMangaId[item.id].orEmpty(),
|
||||
globalAverageChapterDurationMs = globalAverageChapterDurationMs,
|
||||
limits = limits,
|
||||
)
|
||||
item.id to computeEstimatedTimeLeftMs(item, limits)
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateEstimatedTimeLeftMs(
|
||||
private suspend fun computeEstimatedTimeLeftMs(
|
||||
item: LibraryItem,
|
||||
chapterDurations: List<Long>,
|
||||
globalAverageChapterDurationMs: Long?,
|
||||
limits: ReadingEtaLimits,
|
||||
): Long? {
|
||||
val unreadCount = item.libraryManga.unreadCount.coerceAtLeast(0L)
|
||||
if (unreadCount == 0L) return null
|
||||
if (item.libraryManga.unreadCount <= 0L) return null
|
||||
|
||||
val averageChapterDurationMs = calculateAverageChapterDurationMs(chapterDurations, limits)
|
||||
?: globalAverageChapterDurationMs
|
||||
?: return null
|
||||
|
||||
val remainingChapterUnits = if (item.libraryManga.totalChapters == 1L) {
|
||||
1L
|
||||
val manga = item.libraryManga.manga
|
||||
val mergedManga = if (manga.source == MERGED_SOURCE_ID) {
|
||||
getMergedMangaById.await(manga.id).associateBy { it.id }
|
||||
} else {
|
||||
unreadCount
|
||||
emptyMap()
|
||||
}
|
||||
return (averageChapterDurationMs * remainingChapterUnits).coerceAtLeast(0L)
|
||||
}
|
||||
|
||||
private fun calculateAverageChapterDurationMs(
|
||||
chapterDurations: List<Long>,
|
||||
limits: ReadingEtaLimits,
|
||||
): Long? {
|
||||
val sampledDurations = chapterDurations
|
||||
.filter { it in limits.minTrackedChapterDurationMs..limits.maxTrackedChapterDurationMs }
|
||||
.map { it.coerceAtMost(limits.maxChapterDurationMsForStats) }
|
||||
.takeLast(limits.etaSampleChapterLimit)
|
||||
if (sampledDurations.isEmpty()) return null
|
||||
|
||||
val sortedDurations = sampledDurations.sorted()
|
||||
val trimCount = (sortedDurations.size / 10)
|
||||
.coerceAtMost((sortedDurations.size - 1) / 2)
|
||||
val trimmedDurations = if (trimCount > 0) {
|
||||
sortedDurations.subList(trimCount, sortedDurations.size - trimCount)
|
||||
val rawChapters = if (manga.source == MERGED_SOURCE_ID) {
|
||||
getMergedChaptersByMangaId.await(manga.id, applyFilter = true)
|
||||
} else {
|
||||
sortedDurations
|
||||
getChaptersByMangaId.await(manga.id, applyFilter = true)
|
||||
}
|
||||
return trimmedDurations.sum() / trimmedDurations.size
|
||||
val orderedChapters = if (manga.source == MERGED_SOURCE_ID) {
|
||||
getMergedChaptersByMangaId.await(manga.id, applyFilter = false)
|
||||
} else {
|
||||
getChaptersByMangaId.await(manga.id, applyFilter = false)
|
||||
}.sortedWith(getChapterSort(manga, sortDescending = false))
|
||||
if (orderedChapters.isEmpty()) return null
|
||||
|
||||
val nextUnread = rawChapters.getNextUnread(manga, downloadManager, mergedManga) ?: return null
|
||||
val histories = getReadDurationEntriesByMangaIds.await(manga.id)
|
||||
val crossMangaSamples = getCrossMangaPageRateHistorySamples.await(manga.id)
|
||||
val currentPage = if (nextUnread.lastPageRead > 0L) {
|
||||
(nextUnread.lastPageRead + 1L).toInt()
|
||||
} else {
|
||||
1
|
||||
}
|
||||
|
||||
return SeriesReadingEtaCalculator.estimate(
|
||||
SeriesReadingEtaCalculator.Input(
|
||||
orderedChapterIds = orderedChapters.map { it.id },
|
||||
chapterReadById = orderedChapters.associate { it.id to it.read },
|
||||
currentChapterId = nextUnread.id,
|
||||
currentPage = currentPage,
|
||||
totalPages = -1,
|
||||
chapterDurations = histories.associate { it.chapterId to it.readDuration },
|
||||
chapterPages = histories.associate { it.chapterId to it.readPages },
|
||||
crossMangaPageRateSamples = crossMangaSamples,
|
||||
limits = limits,
|
||||
),
|
||||
)?.etaMillis
|
||||
}
|
||||
|
||||
private fun compareEstimatedTimeLeft(
|
||||
|
|
@ -906,7 +905,8 @@ class LibraryScreenModel(
|
|||
getLibraryManga.subscribe(),
|
||||
getLibraryItemPreferencesFlow(),
|
||||
downloadCache.changes,
|
||||
) { libraryManga, preferences, _ ->
|
||||
getHistoryRevision.subscribe(),
|
||||
) { libraryManga, preferences, _, _ ->
|
||||
libraryManga.map { manga ->
|
||||
// Display mode based on user preference: take it from global library setting or category
|
||||
// KMK -->
|
||||
|
|
|
|||
|
|
@ -99,9 +99,9 @@ import tachiyomi.domain.history.interactor.UpsertHistory
|
|||
import tachiyomi.domain.history.model.HistoryUpdate
|
||||
import tachiyomi.domain.library.service.LibraryPreferences
|
||||
import tachiyomi.domain.reading.ReadingEtaHistorySample
|
||||
import tachiyomi.domain.reading.ReadingEtaPageRateEstimator
|
||||
import tachiyomi.domain.reading.ReadingEtaDefaults
|
||||
import tachiyomi.domain.reading.ReadingEtaLimits
|
||||
import tachiyomi.domain.reading.SeriesReadingEtaCalculator
|
||||
import tachiyomi.domain.reading.service.ReadingEtaPreferences
|
||||
import tachiyomi.domain.manga.interactor.GetFlatMetadataById
|
||||
import tachiyomi.domain.manga.interactor.GetManga
|
||||
|
|
@ -269,7 +269,6 @@ class ReaderViewModel @JvmOverloads constructor(
|
|||
private var crossMangaPageRateSamples: List<ReadingEtaHistorySample> = emptyList()
|
||||
|
||||
private var orderedSeriesChapterIds = emptyList<Long>()
|
||||
private var seriesChapterIndexById = emptyMap<Long, Int>()
|
||||
|
||||
private var chapterToDownload: Download? = null
|
||||
|
||||
|
|
@ -972,9 +971,6 @@ class ReaderViewModel @JvmOverloads constructor(
|
|||
orderedSeriesChapterIds = unfilteredChapterList
|
||||
.sortedWith(getChapterSort(manga, sortDescending = false))
|
||||
.mapNotNull { it.id }
|
||||
seriesChapterIndexById = orderedSeriesChapterIds
|
||||
.withIndex()
|
||||
.associate { (index, chapterId) -> chapterId to index }
|
||||
|
||||
chapterReadDurationsSession.clear()
|
||||
chapterReadDurationsPersistent.clear()
|
||||
|
|
@ -1000,94 +996,49 @@ class ReaderViewModel @JvmOverloads constructor(
|
|||
return
|
||||
}
|
||||
|
||||
val currentChapterIndex = seriesChapterIndexById[currentChapterId]
|
||||
if (currentChapterIndex == null) {
|
||||
val limits = readingEtaPreferences.currentLimits()
|
||||
val chapterDurations = getEtaChapterDurations(currentChapterId, limits)
|
||||
val chapterPages = getEtaChapterPages(currentChapterId, limits)
|
||||
val activeSession = chapterReadStartTime?.let { startTime ->
|
||||
chapterReadStartPageIndex?.let { startPageIndex ->
|
||||
val totalPages = state.value.totalPages
|
||||
if (totalPages <= 1) {
|
||||
null
|
||||
} else {
|
||||
SeriesReadingEtaCalculator.ActiveSession(
|
||||
sessionDurationMs = SystemClock.elapsedRealtime() - startTime,
|
||||
startPageIndex = startPageIndex,
|
||||
currentPageIndex = (state.value.currentPage - 1).coerceAtLeast(startPageIndex),
|
||||
totalPages = totalPages,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val result = SeriesReadingEtaCalculator.estimate(
|
||||
SeriesReadingEtaCalculator.Input(
|
||||
orderedChapterIds = orderedSeriesChapterIds,
|
||||
chapterReadById = unfilteredChapterList.associate { it.id to it.read },
|
||||
currentChapterId = currentChapterId,
|
||||
currentPage = state.value.currentPage,
|
||||
totalPages = state.value.totalPages,
|
||||
chapterDurations = chapterDurations,
|
||||
chapterPages = chapterPages,
|
||||
crossMangaPageRateSamples = crossMangaPageRateSamples,
|
||||
limits = limits,
|
||||
activeSession = activeSession,
|
||||
),
|
||||
) ?: run {
|
||||
mutableState.update { it.copy(seriesReadingProgress = null) }
|
||||
return
|
||||
}
|
||||
|
||||
val totalChapters = orderedSeriesChapterIds.size
|
||||
val isCurrentChapterRead = state.value.currentChapter?.chapter?.read == true
|
||||
val remainingChapters = (
|
||||
totalChapters - currentChapterIndex - if (isCurrentChapterRead) 1 else 0
|
||||
).coerceAtLeast(0)
|
||||
val limits = readingEtaPreferences.currentLimits()
|
||||
val chapterDurations = getEtaChapterDurations(currentChapterId, limits)
|
||||
val chapterPages = getEtaChapterPages(currentChapterId, limits)
|
||||
val currentChapterProgress = calculateChapterProgress(
|
||||
currentPage = state.value.currentPage,
|
||||
totalPages = state.value.totalPages,
|
||||
)
|
||||
val projectedCurrentChapterDuration =
|
||||
estimateFullChapterMsFromActiveSession(limits)
|
||||
?.coerceAtMost(limits.maxChapterDurationMsForStats)
|
||||
?: calculateProjectedChapterDurationMs(
|
||||
chapterDuration = chapterDurations[currentChapterId] ?: 0L,
|
||||
chapterProgress = currentChapterProgress,
|
||||
limits = limits,
|
||||
)
|
||||
val averageChapterDuration = calculateAverageChapterDurationMs(
|
||||
chapterDurations = chapterDurations,
|
||||
currentChapterId = currentChapterId,
|
||||
currentChapterIndex = currentChapterIndex,
|
||||
isCurrentChapterRead = isCurrentChapterRead,
|
||||
currentChapterProgress = currentChapterProgress,
|
||||
limits = limits,
|
||||
)
|
||||
val pageBasedEta = calculatePageBasedEtaMillis(
|
||||
chapterDurations = chapterDurations,
|
||||
chapterPages = chapterPages,
|
||||
currentChapterId = currentChapterId,
|
||||
currentChapterIndex = currentChapterIndex,
|
||||
isCurrentChapterRead = isCurrentChapterRead,
|
||||
currentChapterProgress = currentChapterProgress,
|
||||
totalPages = state.value.totalPages,
|
||||
currentPage = state.value.currentPage,
|
||||
remainingChapters = remainingChapters,
|
||||
limits = limits,
|
||||
)
|
||||
val chapterDurationForWholeChapters = averageChapterDuration ?: projectedCurrentChapterDuration
|
||||
val chapterDurationForCurrentChapter = projectedCurrentChapterDuration ?: chapterDurationForWholeChapters
|
||||
val chapterBasedEta = when {
|
||||
totalChapters == 1 -> {
|
||||
if (isCurrentChapterRead) {
|
||||
0L
|
||||
} else {
|
||||
calculateRemainingChapterTimeMs(
|
||||
chapterDuration = chapterDurationForCurrentChapter,
|
||||
chapterProgress = currentChapterProgress,
|
||||
) ?: chapterDurationForCurrentChapter
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
when {
|
||||
remainingChapters == 0 -> 0L
|
||||
else -> chapterDurationForWholeChapters?.let { chapterDurationMs ->
|
||||
val currentChapterRemainingMs = if (isCurrentChapterRead) {
|
||||
0L
|
||||
} else {
|
||||
calculateRemainingChapterTimeMs(
|
||||
chapterDuration = chapterDurationForCurrentChapter,
|
||||
chapterProgress = currentChapterProgress,
|
||||
) ?: chapterDurationForCurrentChapter ?: 0L
|
||||
}
|
||||
val remainingWholeChapters = (
|
||||
remainingChapters - if (isCurrentChapterRead) 0 else 1
|
||||
).coerceAtLeast(0)
|
||||
currentChapterRemainingMs + (chapterDurationMs * remainingWholeChapters)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val etaMillis = (pageBasedEta ?: chapterBasedEta)?.coerceAtLeast(0L)
|
||||
|
||||
mutableState.update {
|
||||
it.copy(
|
||||
seriesReadingProgress = SeriesReadingProgress(
|
||||
currentChapter = currentChapterIndex + 1,
|
||||
totalChapters = totalChapters,
|
||||
remainingChapters = remainingChapters,
|
||||
etaMillis = etaMillis,
|
||||
currentChapter = result.currentChapter,
|
||||
totalChapters = result.totalChapters,
|
||||
remainingChapters = result.remainingChapters,
|
||||
etaMillis = result.etaMillis,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -1133,157 +1084,6 @@ class ReaderViewModel @JvmOverloads constructor(
|
|||
return trackedPages
|
||||
}
|
||||
|
||||
private fun calculatePageBasedEtaMillis(
|
||||
chapterDurations: Map<Long, Long>,
|
||||
chapterPages: Map<Long, Long>,
|
||||
currentChapterId: Long,
|
||||
currentChapterIndex: Int,
|
||||
isCurrentChapterRead: Boolean,
|
||||
currentChapterProgress: Double?,
|
||||
totalPages: Int,
|
||||
currentPage: Int,
|
||||
remainingChapters: Int,
|
||||
limits: ReadingEtaLimits,
|
||||
): Long? {
|
||||
val seriesPageRatesMsPerPage = orderedSeriesChapterIds
|
||||
.take(currentChapterIndex + 1)
|
||||
.mapNotNull { chapterId ->
|
||||
val duration = chapterDurations[chapterId]?.sanitizeTrackedDuration(limits) ?: return@mapNotNull null
|
||||
val pages = chapterPages[chapterId]?.sanitizeTrackedPages(limits) ?: return@mapNotNull null
|
||||
if (duration <= 0L || pages <= 0L) return@mapNotNull null
|
||||
duration.toDouble() / pages.toDouble()
|
||||
}
|
||||
.takeLast(limits.etaSampleChapterLimit)
|
||||
|
||||
val sampledPages = orderedSeriesChapterIds
|
||||
.take(currentChapterIndex + 1)
|
||||
.mapNotNull { chapterPages[it]?.sanitizeTrackedPages(limits) }
|
||||
.takeLast(limits.etaSampleChapterLimit)
|
||||
if (sampledPages.isEmpty()) return null
|
||||
val avgPagesPerChapter = (sampledPages.sum() / sampledPages.size).coerceAtLeast(1L)
|
||||
|
||||
if (seriesPageRatesMsPerPage.isEmpty() && crossMangaPageRateSamples.isEmpty()) return null
|
||||
|
||||
val targetChapterPages = if (totalPages > 0) {
|
||||
totalPages
|
||||
} else {
|
||||
avgPagesPerChapter.toInt().coerceAtLeast(1)
|
||||
}
|
||||
|
||||
val avgMsPerPage = ReadingEtaPageRateEstimator.estimateBlendedMsPerPage(
|
||||
targetChapterPages = targetChapterPages,
|
||||
seriesPageRatesMsPerPage = seriesPageRatesMsPerPage,
|
||||
crossMangaSamples = crossMangaPageRateSamples,
|
||||
limits = limits,
|
||||
) ?: return null
|
||||
|
||||
val currentRemainingPages = if (isCurrentChapterRead) {
|
||||
0L
|
||||
} else if (totalPages > 0 && currentPage > 0) {
|
||||
(totalPages - currentPage).toLong().coerceAtLeast(0L)
|
||||
} else {
|
||||
val progress = (currentChapterProgress ?: 0.0).coerceIn(0.0, 1.0)
|
||||
((1.0 - progress) * avgPagesPerChapter.toDouble()).toLong().coerceAtLeast(0L)
|
||||
}
|
||||
val remainingWholeChapters = (remainingChapters - if (isCurrentChapterRead) 0 else 1).coerceAtLeast(0)
|
||||
return currentRemainingPages * avgMsPerPage + remainingWholeChapters * avgPagesPerChapter * avgMsPerPage
|
||||
}
|
||||
|
||||
private fun calculateAverageChapterDurationMs(
|
||||
chapterDurations: Map<Long, Long>,
|
||||
currentChapterId: Long,
|
||||
currentChapterIndex: Int,
|
||||
isCurrentChapterRead: Boolean,
|
||||
currentChapterProgress: Double?,
|
||||
limits: ReadingEtaLimits,
|
||||
): Long? {
|
||||
if (currentChapterIndex < 0) return null
|
||||
val sampledDurations = orderedSeriesChapterIds
|
||||
.take(currentChapterIndex + 1)
|
||||
.mapNotNull { chapterId ->
|
||||
val chapterDuration = chapterDurations[chapterId] ?: return@mapNotNull null
|
||||
val normalizedDuration = when {
|
||||
chapterId == currentChapterId && !isCurrentChapterRead -> {
|
||||
calculateProjectedChapterDurationMs(
|
||||
chapterDuration = chapterDuration,
|
||||
chapterProgress = currentChapterProgress,
|
||||
limits = limits,
|
||||
)
|
||||
}
|
||||
else -> chapterDuration
|
||||
} ?: return@mapNotNull null
|
||||
normalizedDuration
|
||||
}
|
||||
.filter { it in limits.minTrackedChapterDurationMs..limits.maxTrackedChapterDurationMs }
|
||||
.map { it.coerceAtMost(limits.maxChapterDurationMsForStats) }
|
||||
.takeLast(limits.etaSampleChapterLimit)
|
||||
if (sampledDurations.isEmpty()) return null
|
||||
|
||||
val sortedDurations = sampledDurations.sorted()
|
||||
val trimCount = (sortedDurations.size / 10)
|
||||
.coerceAtMost((sortedDurations.size - 1) / 2)
|
||||
val trimmedDurations = if (trimCount > 0) {
|
||||
sortedDurations.subList(trimCount, sortedDurations.size - trimCount)
|
||||
} else {
|
||||
sortedDurations
|
||||
}
|
||||
return trimmedDurations.sum() / trimmedDurations.size
|
||||
}
|
||||
|
||||
private fun calculateChapterProgress(
|
||||
currentPage: Int,
|
||||
totalPages: Int,
|
||||
): Double? {
|
||||
if (currentPage <= 0 || totalPages <= 1) return null
|
||||
return (currentPage.toDouble() / totalPages.toDouble()).coerceIn(0.0, 1.0)
|
||||
}
|
||||
|
||||
private fun calculateProjectedChapterDurationMs(
|
||||
chapterDuration: Long,
|
||||
chapterProgress: Double?,
|
||||
limits: ReadingEtaLimits,
|
||||
): Long? {
|
||||
if (chapterDuration <= 0L) return null
|
||||
val progress = chapterProgress
|
||||
?.takeIf { it >= MIN_PROGRESS_FOR_DURATION_PROJECTION }
|
||||
?.coerceIn(0.0, 1.0)
|
||||
?: return null
|
||||
if (progress == 0.0) return null
|
||||
return (chapterDuration.toDouble() / progress).toLong()
|
||||
.coerceAtLeast(0L)
|
||||
.coerceAtMost(limits.maxChapterDurationMsForStats)
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimates full-chapter duration from the current session's ms/page rate.
|
||||
* Prefer over scaling cumulative DB read time by absolute chapter progress — history times are
|
||||
* summed across revisits, which inflates `duration / progress` for rereads.
|
||||
*/
|
||||
private fun estimateFullChapterMsFromActiveSession(limits: ReadingEtaLimits): Long? {
|
||||
val totalPages = state.value.totalPages
|
||||
val startPageIndex = chapterReadStartPageIndex ?: return null
|
||||
val startTime = chapterReadStartTime ?: return null
|
||||
if (totalPages <= 1) return null
|
||||
|
||||
val sessionMs = (SystemClock.elapsedRealtime() - startTime).sanitizeTrackedDuration(limits)
|
||||
if (sessionMs < limits.minTrackedChapterDurationMs) return null
|
||||
|
||||
val currentPageIndex = (state.value.currentPage - 1).coerceAtLeast(startPageIndex)
|
||||
val pagesThisSession = (currentPageIndex - startPageIndex + 1).coerceAtLeast(1)
|
||||
val msPerPage = sessionMs.toDouble() / pagesThisSession.toDouble()
|
||||
val estimated = (msPerPage * totalPages.toDouble()).toLong()
|
||||
return estimated.takeIf { it > 0L }
|
||||
}
|
||||
|
||||
private fun calculateRemainingChapterTimeMs(
|
||||
chapterDuration: Long?,
|
||||
chapterProgress: Double?,
|
||||
): Long? {
|
||||
val duration = chapterDuration?.takeIf { it > 0L } ?: return null
|
||||
val remainingProgress = 1.0 - (chapterProgress ?: 0.0).coerceIn(0.0, 1.0)
|
||||
return (duration.toDouble() * remainingProgress).toLong().coerceAtLeast(0L)
|
||||
}
|
||||
|
||||
private fun Long.sanitizeTrackedDuration(limits: ReadingEtaLimits): Long {
|
||||
return if (this in limits.minTrackedChapterDurationMs..limits.maxTrackedChapterDurationMs) this else 0L
|
||||
}
|
||||
|
|
@ -1842,10 +1642,6 @@ class ReaderViewModel @JvmOverloads constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MIN_PROGRESS_FOR_DURATION_PROJECTION = 0.05
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class State(
|
||||
val manga: Manga? = null,
|
||||
|
|
|
|||
|
|
@ -57,6 +57,10 @@ class HistoryRepositoryImpl(
|
|||
return handler.awaitList { historyQueries.getHistoryByMangaId(mangaId, HistoryMapper::mapHistory) }
|
||||
}
|
||||
|
||||
override fun subscribeToRevision(): Flow<Long> {
|
||||
return handler.subscribeToOne { historyQueries.historyRevision() }
|
||||
}
|
||||
|
||||
override suspend fun getCrossMangaPageRateHistorySamples(
|
||||
excludeMangaId: Long,
|
||||
minDurationMs: Long,
|
||||
|
|
|
|||
|
|
@ -83,6 +83,10 @@ getReadDuration:
|
|||
SELECT coalesce(sum(time_read), 0)
|
||||
FROM history;
|
||||
|
||||
historyRevision:
|
||||
SELECT coalesce(sum(time_read), 0) + coalesce(sum(pages_read), 0) + count(*)
|
||||
FROM history;
|
||||
|
||||
-- Recent completed-chapter history from other series, for data-driven ms/page ETA priors.
|
||||
getCrossMangaPageRateHistorySamples:
|
||||
SELECT
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
package tachiyomi.domain.history.interactor
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import tachiyomi.domain.history.repository.HistoryRepository
|
||||
|
||||
class GetHistoryRevision(
|
||||
private val repository: HistoryRepository,
|
||||
) {
|
||||
fun subscribe(): Flow<Long> = repository.subscribeToRevision()
|
||||
}
|
||||
|
|
@ -23,6 +23,8 @@ interface HistoryRepository {
|
|||
|
||||
suspend fun getHistoryByMangaId(mangaId: Long): List<History>
|
||||
|
||||
fun subscribeToRevision(): Flow<Long>
|
||||
|
||||
suspend fun getCrossMangaPageRateHistorySamples(
|
||||
excludeMangaId: Long,
|
||||
minDurationMs: Long,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,311 @@
|
|||
package tachiyomi.domain.reading
|
||||
|
||||
/**
|
||||
* Shared series reading-time estimate used by the reader overlay and library badges.
|
||||
*/
|
||||
object SeriesReadingEtaCalculator {
|
||||
|
||||
const val MIN_PROGRESS_FOR_DURATION_PROJECTION = 0.05
|
||||
|
||||
data class ActiveSession(
|
||||
val sessionDurationMs: Long,
|
||||
val startPageIndex: Int,
|
||||
val currentPageIndex: Int,
|
||||
val totalPages: Int,
|
||||
)
|
||||
|
||||
data class Input(
|
||||
val orderedChapterIds: List<Long>,
|
||||
val chapterReadById: Map<Long, Boolean>,
|
||||
val currentChapterId: Long,
|
||||
val currentPage: Int,
|
||||
val totalPages: Int,
|
||||
val chapterDurations: Map<Long, Long>,
|
||||
val chapterPages: Map<Long, Long>,
|
||||
val crossMangaPageRateSamples: List<ReadingEtaHistorySample>,
|
||||
val limits: ReadingEtaLimits,
|
||||
val activeSession: ActiveSession? = null,
|
||||
)
|
||||
|
||||
data class Result(
|
||||
val currentChapter: Int,
|
||||
val totalChapters: Int,
|
||||
val remainingChapters: Int,
|
||||
val etaMillis: Long?,
|
||||
)
|
||||
|
||||
fun estimate(input: Input): Result? {
|
||||
if (input.orderedChapterIds.isEmpty()) return null
|
||||
|
||||
val chapterIndexById = input.orderedChapterIds
|
||||
.withIndex()
|
||||
.associate { (index, chapterId) -> chapterId to index }
|
||||
val currentChapterIndex = chapterIndexById[input.currentChapterId] ?: return null
|
||||
|
||||
val totalChapters = input.orderedChapterIds.size
|
||||
val isCurrentChapterRead = input.chapterReadById[input.currentChapterId] == true
|
||||
val remainingChapters = (
|
||||
totalChapters - currentChapterIndex - if (isCurrentChapterRead) 1 else 0
|
||||
).coerceAtLeast(0)
|
||||
|
||||
val chapterDurations = buildChapterDurations(input)
|
||||
val chapterPages = buildChapterPages(input)
|
||||
val currentChapterProgress = calculateChapterProgress(
|
||||
currentPage = input.currentPage,
|
||||
totalPages = input.totalPages,
|
||||
)
|
||||
val projectedCurrentChapterDuration =
|
||||
estimateFullChapterMsFromActiveSession(input)
|
||||
?.coerceAtMost(input.limits.maxChapterDurationMsForStats)
|
||||
?: calculateProjectedChapterDurationMs(
|
||||
chapterDuration = chapterDurations[input.currentChapterId] ?: 0L,
|
||||
chapterProgress = currentChapterProgress,
|
||||
limits = input.limits,
|
||||
)
|
||||
val averageChapterDuration = calculateAverageChapterDurationMs(
|
||||
orderedChapterIds = input.orderedChapterIds,
|
||||
chapterDurations = chapterDurations,
|
||||
currentChapterId = input.currentChapterId,
|
||||
currentChapterIndex = currentChapterIndex,
|
||||
isCurrentChapterRead = isCurrentChapterRead,
|
||||
currentChapterProgress = currentChapterProgress,
|
||||
limits = input.limits,
|
||||
)
|
||||
val pageBasedEta = calculatePageBasedEtaMillis(
|
||||
orderedChapterIds = input.orderedChapterIds,
|
||||
chapterDurations = chapterDurations,
|
||||
chapterPages = chapterPages,
|
||||
currentChapterId = input.currentChapterId,
|
||||
currentChapterIndex = currentChapterIndex,
|
||||
isCurrentChapterRead = isCurrentChapterRead,
|
||||
currentChapterProgress = currentChapterProgress,
|
||||
totalPages = input.totalPages,
|
||||
currentPage = input.currentPage,
|
||||
remainingChapters = remainingChapters,
|
||||
crossMangaPageRateSamples = input.crossMangaPageRateSamples,
|
||||
limits = input.limits,
|
||||
)
|
||||
val chapterDurationForWholeChapters = averageChapterDuration ?: projectedCurrentChapterDuration
|
||||
val chapterDurationForCurrentChapter = projectedCurrentChapterDuration ?: chapterDurationForWholeChapters
|
||||
val chapterBasedEta = when {
|
||||
totalChapters == 1 -> {
|
||||
if (isCurrentChapterRead) {
|
||||
0L
|
||||
} else {
|
||||
calculateRemainingChapterTimeMs(
|
||||
chapterDuration = chapterDurationForCurrentChapter,
|
||||
chapterProgress = currentChapterProgress,
|
||||
) ?: chapterDurationForCurrentChapter
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
when {
|
||||
remainingChapters == 0 -> 0L
|
||||
else -> chapterDurationForWholeChapters?.let { chapterDurationMs ->
|
||||
val currentChapterRemainingMs = if (isCurrentChapterRead) {
|
||||
0L
|
||||
} else {
|
||||
calculateRemainingChapterTimeMs(
|
||||
chapterDuration = chapterDurationForCurrentChapter,
|
||||
chapterProgress = currentChapterProgress,
|
||||
) ?: chapterDurationForCurrentChapter ?: 0L
|
||||
}
|
||||
val remainingWholeChapters = (
|
||||
remainingChapters - if (isCurrentChapterRead) 0 else 1
|
||||
).coerceAtLeast(0)
|
||||
currentChapterRemainingMs + (chapterDurationMs * remainingWholeChapters)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val etaMillis = (pageBasedEta ?: chapterBasedEta)?.coerceAtLeast(0L)
|
||||
|
||||
return Result(
|
||||
currentChapter = currentChapterIndex + 1,
|
||||
totalChapters = totalChapters,
|
||||
remainingChapters = remainingChapters,
|
||||
etaMillis = etaMillis,
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildChapterDurations(input: Input): Map<Long, Long> {
|
||||
val trackedDurations = input.chapterDurations.toMutableMap()
|
||||
val activeSession = input.activeSession ?: return trackedDurations
|
||||
|
||||
val inProgressDuration = activeSession.sessionDurationMs
|
||||
.sanitizeTrackedDuration(input.limits)
|
||||
if (inProgressDuration > 0L) {
|
||||
trackedDurations.merge(input.currentChapterId, inProgressDuration, Long::plus)
|
||||
}
|
||||
return trackedDurations
|
||||
}
|
||||
|
||||
private fun buildChapterPages(input: Input): Map<Long, Long> {
|
||||
val trackedPages = input.chapterPages.toMutableMap()
|
||||
val activeSession = input.activeSession ?: return trackedPages
|
||||
|
||||
val inProgressPages = (
|
||||
activeSession.currentPageIndex - activeSession.startPageIndex + 1L
|
||||
).sanitizeTrackedPages(input.limits)
|
||||
if (inProgressPages > 0L) {
|
||||
trackedPages.merge(input.currentChapterId, inProgressPages, Long::plus)
|
||||
}
|
||||
return trackedPages
|
||||
}
|
||||
|
||||
private fun estimateFullChapterMsFromActiveSession(input: Input): Long? {
|
||||
val activeSession = input.activeSession ?: return null
|
||||
if (activeSession.totalPages <= 1) return null
|
||||
|
||||
val sessionMs = activeSession.sessionDurationMs.sanitizeTrackedDuration(input.limits)
|
||||
if (sessionMs < input.limits.minTrackedChapterDurationMs) return null
|
||||
|
||||
val pagesThisSession = (
|
||||
activeSession.currentPageIndex - activeSession.startPageIndex + 1
|
||||
).coerceAtLeast(1)
|
||||
val msPerPage = sessionMs.toDouble() / pagesThisSession.toDouble()
|
||||
val estimated = (msPerPage * activeSession.totalPages.toDouble()).toLong()
|
||||
return estimated.takeIf { it > 0L }
|
||||
}
|
||||
|
||||
private fun calculatePageBasedEtaMillis(
|
||||
orderedChapterIds: List<Long>,
|
||||
chapterDurations: Map<Long, Long>,
|
||||
chapterPages: Map<Long, Long>,
|
||||
currentChapterId: Long,
|
||||
currentChapterIndex: Int,
|
||||
isCurrentChapterRead: Boolean,
|
||||
currentChapterProgress: Double?,
|
||||
totalPages: Int,
|
||||
currentPage: Int,
|
||||
remainingChapters: Int,
|
||||
crossMangaPageRateSamples: List<ReadingEtaHistorySample>,
|
||||
limits: ReadingEtaLimits,
|
||||
): Long? {
|
||||
val seriesPageRatesMsPerPage = orderedChapterIds
|
||||
.take(currentChapterIndex + 1)
|
||||
.mapNotNull { chapterId ->
|
||||
val duration = chapterDurations[chapterId]?.sanitizeTrackedDuration(limits) ?: return@mapNotNull null
|
||||
val pages = chapterPages[chapterId]?.sanitizeTrackedPages(limits) ?: return@mapNotNull null
|
||||
if (duration <= 0L || pages <= 0L) return@mapNotNull null
|
||||
duration.toDouble() / pages.toDouble()
|
||||
}
|
||||
.takeLast(limits.etaSampleChapterLimit)
|
||||
|
||||
val sampledPages = orderedChapterIds
|
||||
.take(currentChapterIndex + 1)
|
||||
.mapNotNull { chapterPages[it]?.sanitizeTrackedPages(limits) }
|
||||
.takeLast(limits.etaSampleChapterLimit)
|
||||
if (sampledPages.isEmpty()) return null
|
||||
val avgPagesPerChapter = (sampledPages.sum() / sampledPages.size).coerceAtLeast(1L)
|
||||
|
||||
if (seriesPageRatesMsPerPage.isEmpty() && crossMangaPageRateSamples.isEmpty()) return null
|
||||
|
||||
val targetChapterPages = if (totalPages > 0) {
|
||||
totalPages
|
||||
} else {
|
||||
avgPagesPerChapter.toInt().coerceAtLeast(1)
|
||||
}
|
||||
|
||||
val avgMsPerPage = ReadingEtaPageRateEstimator.estimateBlendedMsPerPage(
|
||||
targetChapterPages = targetChapterPages,
|
||||
seriesPageRatesMsPerPage = seriesPageRatesMsPerPage,
|
||||
crossMangaSamples = crossMangaPageRateSamples,
|
||||
limits = limits,
|
||||
) ?: return null
|
||||
|
||||
val currentRemainingPages = if (isCurrentChapterRead) {
|
||||
0L
|
||||
} else if (totalPages > 0 && currentPage > 0) {
|
||||
(totalPages - currentPage).toLong().coerceAtLeast(0L)
|
||||
} else {
|
||||
val progress = (currentChapterProgress ?: 0.0).coerceIn(0.0, 1.0)
|
||||
((1.0 - progress) * avgPagesPerChapter.toDouble()).toLong().coerceAtLeast(0L)
|
||||
}
|
||||
val remainingWholeChapters = (remainingChapters - if (isCurrentChapterRead) 0 else 1).coerceAtLeast(0)
|
||||
return currentRemainingPages * avgMsPerPage + remainingWholeChapters * avgPagesPerChapter * avgMsPerPage
|
||||
}
|
||||
|
||||
private fun calculateAverageChapterDurationMs(
|
||||
orderedChapterIds: List<Long>,
|
||||
chapterDurations: Map<Long, Long>,
|
||||
currentChapterId: Long,
|
||||
currentChapterIndex: Int,
|
||||
isCurrentChapterRead: Boolean,
|
||||
currentChapterProgress: Double?,
|
||||
limits: ReadingEtaLimits,
|
||||
): Long? {
|
||||
if (currentChapterIndex < 0) return null
|
||||
val sampledDurations = orderedChapterIds
|
||||
.take(currentChapterIndex + 1)
|
||||
.mapNotNull { chapterId ->
|
||||
val chapterDuration = chapterDurations[chapterId] ?: return@mapNotNull null
|
||||
val normalizedDuration = when {
|
||||
chapterId == currentChapterId && !isCurrentChapterRead -> {
|
||||
calculateProjectedChapterDurationMs(
|
||||
chapterDuration = chapterDuration,
|
||||
chapterProgress = currentChapterProgress,
|
||||
limits = limits,
|
||||
)
|
||||
}
|
||||
else -> chapterDuration
|
||||
} ?: return@mapNotNull null
|
||||
normalizedDuration
|
||||
}
|
||||
.filter { it in limits.minTrackedChapterDurationMs..limits.maxTrackedChapterDurationMs }
|
||||
.map { it.coerceAtMost(limits.maxChapterDurationMsForStats) }
|
||||
.takeLast(limits.etaSampleChapterLimit)
|
||||
if (sampledDurations.isEmpty()) return null
|
||||
|
||||
val sortedDurations = sampledDurations.sorted()
|
||||
val trimCount = (sortedDurations.size / 10)
|
||||
.coerceAtMost((sortedDurations.size - 1) / 2)
|
||||
val trimmedDurations = if (trimCount > 0) {
|
||||
sortedDurations.subList(trimCount, sortedDurations.size - trimCount)
|
||||
} else {
|
||||
sortedDurations
|
||||
}
|
||||
return trimmedDurations.sum() / trimmedDurations.size
|
||||
}
|
||||
|
||||
private fun calculateChapterProgress(
|
||||
currentPage: Int,
|
||||
totalPages: Int,
|
||||
): Double? {
|
||||
if (currentPage <= 0 || totalPages <= 1) return null
|
||||
return (currentPage.toDouble() / totalPages.toDouble()).coerceIn(0.0, 1.0)
|
||||
}
|
||||
|
||||
private fun calculateProjectedChapterDurationMs(
|
||||
chapterDuration: Long,
|
||||
chapterProgress: Double?,
|
||||
limits: ReadingEtaLimits,
|
||||
): Long? {
|
||||
if (chapterDuration <= 0L) return null
|
||||
val progress = chapterProgress
|
||||
?.takeIf { it >= MIN_PROGRESS_FOR_DURATION_PROJECTION }
|
||||
?.coerceIn(0.0, 1.0)
|
||||
?: return null
|
||||
if (progress == 0.0) return null
|
||||
return (chapterDuration.toDouble() / progress).toLong()
|
||||
.coerceAtLeast(0L)
|
||||
.coerceAtMost(limits.maxChapterDurationMsForStats)
|
||||
}
|
||||
|
||||
private fun calculateRemainingChapterTimeMs(
|
||||
chapterDuration: Long?,
|
||||
chapterProgress: Double?,
|
||||
): Long? {
|
||||
val duration = chapterDuration?.takeIf { it > 0L } ?: return null
|
||||
val remainingProgress = 1.0 - (chapterProgress ?: 0.0).coerceIn(0.0, 1.0)
|
||||
return (duration.toDouble() * remainingProgress).toLong().coerceAtLeast(0L)
|
||||
}
|
||||
|
||||
private fun Long.sanitizeTrackedDuration(limits: ReadingEtaLimits): Long {
|
||||
return if (this in limits.minTrackedChapterDurationMs..limits.maxTrackedChapterDurationMs) this else 0L
|
||||
}
|
||||
|
||||
private fun Long.sanitizeTrackedPages(limits: ReadingEtaLimits): Long {
|
||||
return if (this in ReadingEtaDefaults.MIN_PAGES_READ_FOR_PAGE_RATE..limits.maxPagesReadForPageRate) this else 0L
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue