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.chapter.repository.ChapterRepository
|
||||||
import tachiyomi.domain.history.interactor.GetCrossMangaPageRateHistorySamples
|
import tachiyomi.domain.history.interactor.GetCrossMangaPageRateHistorySamples
|
||||||
import tachiyomi.domain.history.interactor.GetHistory
|
import tachiyomi.domain.history.interactor.GetHistory
|
||||||
|
import tachiyomi.domain.history.interactor.GetHistoryRevision
|
||||||
import tachiyomi.domain.history.interactor.GetNextChapters
|
import tachiyomi.domain.history.interactor.GetNextChapters
|
||||||
import tachiyomi.domain.history.interactor.GetReadDurationEntriesByMangaIds
|
import tachiyomi.domain.history.interactor.GetReadDurationEntriesByMangaIds
|
||||||
import tachiyomi.domain.history.interactor.GetTotalReadDuration
|
import tachiyomi.domain.history.interactor.GetTotalReadDuration
|
||||||
|
|
@ -173,6 +174,7 @@ class DomainModule : InjektModule {
|
||||||
|
|
||||||
addSingletonFactory<HistoryRepository> { HistoryRepositoryImpl(get()) }
|
addSingletonFactory<HistoryRepository> { HistoryRepositoryImpl(get()) }
|
||||||
addFactory { GetHistory(get()) }
|
addFactory { GetHistory(get()) }
|
||||||
|
addFactory { GetHistoryRevision(get()) }
|
||||||
addFactory { UpsertHistory(get()) }
|
addFactory { UpsertHistory(get()) }
|
||||||
addFactory { RemoveHistory(get()) }
|
addFactory { RemoveHistory(get()) }
|
||||||
addFactory { GetTotalReadDuration(get()) }
|
addFactory { GetTotalReadDuration(get()) }
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,9 @@ import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
|
||||||
import tachiyomi.domain.chapter.interactor.GetMergedChaptersByMangaId
|
import tachiyomi.domain.chapter.interactor.GetMergedChaptersByMangaId
|
||||||
import tachiyomi.domain.chapter.model.Chapter
|
import tachiyomi.domain.chapter.model.Chapter
|
||||||
import tachiyomi.domain.history.interactor.GetNextChapters
|
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.GetReadDurationEntriesByMangaIds
|
||||||
import tachiyomi.domain.history.interactor.GetTotalReadDuration
|
import tachiyomi.domain.history.interactor.GetTotalReadDuration
|
||||||
import tachiyomi.domain.library.model.LibraryDisplayMode
|
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.model.sort
|
||||||
import tachiyomi.domain.library.service.LibraryPreferences
|
import tachiyomi.domain.library.service.LibraryPreferences
|
||||||
import tachiyomi.domain.reading.ReadingEtaLimits
|
import tachiyomi.domain.reading.ReadingEtaLimits
|
||||||
|
import tachiyomi.domain.reading.SeriesReadingEtaCalculator
|
||||||
import tachiyomi.domain.reading.service.ReadingEtaPreferences
|
import tachiyomi.domain.reading.service.ReadingEtaPreferences
|
||||||
import tachiyomi.domain.manga.interactor.GetIdsOfFavoriteMangaWithMetadata
|
import tachiyomi.domain.manga.interactor.GetIdsOfFavoriteMangaWithMetadata
|
||||||
import tachiyomi.domain.manga.interactor.GetLibraryManga
|
import tachiyomi.domain.manga.interactor.GetLibraryManga
|
||||||
|
|
@ -153,6 +157,8 @@ class LibraryScreenModel(
|
||||||
private val sourcePreferences: SourcePreferences = Injekt.get(),
|
private val sourcePreferences: SourcePreferences = Injekt.get(),
|
||||||
private val getMergedMangaById: GetMergedMangaById = Injekt.get(),
|
private val getMergedMangaById: GetMergedMangaById = Injekt.get(),
|
||||||
private val getReadDurationEntriesByMangaIds: GetReadDurationEntriesByMangaIds = 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 getTotalReadDuration: GetTotalReadDuration = Injekt.get(),
|
||||||
private val getTracks: GetTracks = Injekt.get(),
|
private val getTracks: GetTracks = Injekt.get(),
|
||||||
private val getIdsOfFavoriteMangaWithMetadata: GetIdsOfFavoriteMangaWithMetadata = Injekt.get(),
|
private val getIdsOfFavoriteMangaWithMetadata: GetIdsOfFavoriteMangaWithMetadata = Injekt.get(),
|
||||||
|
|
@ -447,64 +453,57 @@ class LibraryScreenModel(
|
||||||
): Map<Long, Long?> {
|
): Map<Long, Long?> {
|
||||||
if (favorites.isEmpty()) return emptyMap()
|
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 ->
|
return favorites.associate { item ->
|
||||||
item.id to calculateEstimatedTimeLeftMs(
|
item.id to computeEstimatedTimeLeftMs(item, limits)
|
||||||
item = item,
|
|
||||||
chapterDurations = chapterDurationsByMangaId[item.id].orEmpty(),
|
|
||||||
globalAverageChapterDurationMs = globalAverageChapterDurationMs,
|
|
||||||
limits = limits,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun calculateEstimatedTimeLeftMs(
|
private suspend fun computeEstimatedTimeLeftMs(
|
||||||
item: LibraryItem,
|
item: LibraryItem,
|
||||||
chapterDurations: List<Long>,
|
|
||||||
globalAverageChapterDurationMs: Long?,
|
|
||||||
limits: ReadingEtaLimits,
|
limits: ReadingEtaLimits,
|
||||||
): Long? {
|
): Long? {
|
||||||
val unreadCount = item.libraryManga.unreadCount.coerceAtLeast(0L)
|
if (item.libraryManga.unreadCount <= 0L) return null
|
||||||
if (unreadCount == 0L) return null
|
|
||||||
|
|
||||||
val averageChapterDurationMs = calculateAverageChapterDurationMs(chapterDurations, limits)
|
val manga = item.libraryManga.manga
|
||||||
?: globalAverageChapterDurationMs
|
val mergedManga = if (manga.source == MERGED_SOURCE_ID) {
|
||||||
?: return null
|
getMergedMangaById.await(manga.id).associateBy { it.id }
|
||||||
|
|
||||||
val remainingChapterUnits = if (item.libraryManga.totalChapters == 1L) {
|
|
||||||
1L
|
|
||||||
} else {
|
} else {
|
||||||
unreadCount
|
emptyMap()
|
||||||
}
|
}
|
||||||
return (averageChapterDurationMs * remainingChapterUnits).coerceAtLeast(0L)
|
val rawChapters = if (manga.source == MERGED_SOURCE_ID) {
|
||||||
}
|
getMergedChaptersByMangaId.await(manga.id, applyFilter = true)
|
||||||
|
|
||||||
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)
|
|
||||||
} else {
|
} 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(
|
private fun compareEstimatedTimeLeft(
|
||||||
|
|
@ -906,7 +905,8 @@ class LibraryScreenModel(
|
||||||
getLibraryManga.subscribe(),
|
getLibraryManga.subscribe(),
|
||||||
getLibraryItemPreferencesFlow(),
|
getLibraryItemPreferencesFlow(),
|
||||||
downloadCache.changes,
|
downloadCache.changes,
|
||||||
) { libraryManga, preferences, _ ->
|
getHistoryRevision.subscribe(),
|
||||||
|
) { libraryManga, preferences, _, _ ->
|
||||||
libraryManga.map { manga ->
|
libraryManga.map { manga ->
|
||||||
// Display mode based on user preference: take it from global library setting or category
|
// Display mode based on user preference: take it from global library setting or category
|
||||||
// KMK -->
|
// KMK -->
|
||||||
|
|
|
||||||
|
|
@ -99,9 +99,9 @@ import tachiyomi.domain.history.interactor.UpsertHistory
|
||||||
import tachiyomi.domain.history.model.HistoryUpdate
|
import tachiyomi.domain.history.model.HistoryUpdate
|
||||||
import tachiyomi.domain.library.service.LibraryPreferences
|
import tachiyomi.domain.library.service.LibraryPreferences
|
||||||
import tachiyomi.domain.reading.ReadingEtaHistorySample
|
import tachiyomi.domain.reading.ReadingEtaHistorySample
|
||||||
import tachiyomi.domain.reading.ReadingEtaPageRateEstimator
|
|
||||||
import tachiyomi.domain.reading.ReadingEtaDefaults
|
import tachiyomi.domain.reading.ReadingEtaDefaults
|
||||||
import tachiyomi.domain.reading.ReadingEtaLimits
|
import tachiyomi.domain.reading.ReadingEtaLimits
|
||||||
|
import tachiyomi.domain.reading.SeriesReadingEtaCalculator
|
||||||
import tachiyomi.domain.reading.service.ReadingEtaPreferences
|
import tachiyomi.domain.reading.service.ReadingEtaPreferences
|
||||||
import tachiyomi.domain.manga.interactor.GetFlatMetadataById
|
import tachiyomi.domain.manga.interactor.GetFlatMetadataById
|
||||||
import tachiyomi.domain.manga.interactor.GetManga
|
import tachiyomi.domain.manga.interactor.GetManga
|
||||||
|
|
@ -269,7 +269,6 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||||
private var crossMangaPageRateSamples: List<ReadingEtaHistorySample> = emptyList()
|
private var crossMangaPageRateSamples: List<ReadingEtaHistorySample> = emptyList()
|
||||||
|
|
||||||
private var orderedSeriesChapterIds = emptyList<Long>()
|
private var orderedSeriesChapterIds = emptyList<Long>()
|
||||||
private var seriesChapterIndexById = emptyMap<Long, Int>()
|
|
||||||
|
|
||||||
private var chapterToDownload: Download? = null
|
private var chapterToDownload: Download? = null
|
||||||
|
|
||||||
|
|
@ -972,9 +971,6 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||||
orderedSeriesChapterIds = unfilteredChapterList
|
orderedSeriesChapterIds = unfilteredChapterList
|
||||||
.sortedWith(getChapterSort(manga, sortDescending = false))
|
.sortedWith(getChapterSort(manga, sortDescending = false))
|
||||||
.mapNotNull { it.id }
|
.mapNotNull { it.id }
|
||||||
seriesChapterIndexById = orderedSeriesChapterIds
|
|
||||||
.withIndex()
|
|
||||||
.associate { (index, chapterId) -> chapterId to index }
|
|
||||||
|
|
||||||
chapterReadDurationsSession.clear()
|
chapterReadDurationsSession.clear()
|
||||||
chapterReadDurationsPersistent.clear()
|
chapterReadDurationsPersistent.clear()
|
||||||
|
|
@ -1000,94 +996,49 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val currentChapterIndex = seriesChapterIndexById[currentChapterId]
|
val limits = readingEtaPreferences.currentLimits()
|
||||||
if (currentChapterIndex == null) {
|
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) }
|
mutableState.update { it.copy(seriesReadingProgress = null) }
|
||||||
return
|
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 {
|
mutableState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
seriesReadingProgress = SeriesReadingProgress(
|
seriesReadingProgress = SeriesReadingProgress(
|
||||||
currentChapter = currentChapterIndex + 1,
|
currentChapter = result.currentChapter,
|
||||||
totalChapters = totalChapters,
|
totalChapters = result.totalChapters,
|
||||||
remainingChapters = remainingChapters,
|
remainingChapters = result.remainingChapters,
|
||||||
etaMillis = etaMillis,
|
etaMillis = result.etaMillis,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1133,157 +1084,6 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||||
return trackedPages
|
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 {
|
private fun Long.sanitizeTrackedDuration(limits: ReadingEtaLimits): Long {
|
||||||
return if (this in limits.minTrackedChapterDurationMs..limits.maxTrackedChapterDurationMs) this else 0L
|
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
|
@Immutable
|
||||||
data class State(
|
data class State(
|
||||||
val manga: Manga? = null,
|
val manga: Manga? = null,
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,10 @@ class HistoryRepositoryImpl(
|
||||||
return handler.awaitList { historyQueries.getHistoryByMangaId(mangaId, HistoryMapper::mapHistory) }
|
return handler.awaitList { historyQueries.getHistoryByMangaId(mangaId, HistoryMapper::mapHistory) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun subscribeToRevision(): Flow<Long> {
|
||||||
|
return handler.subscribeToOne { historyQueries.historyRevision() }
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun getCrossMangaPageRateHistorySamples(
|
override suspend fun getCrossMangaPageRateHistorySamples(
|
||||||
excludeMangaId: Long,
|
excludeMangaId: Long,
|
||||||
minDurationMs: Long,
|
minDurationMs: Long,
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,10 @@ getReadDuration:
|
||||||
SELECT coalesce(sum(time_read), 0)
|
SELECT coalesce(sum(time_read), 0)
|
||||||
FROM history;
|
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.
|
-- Recent completed-chapter history from other series, for data-driven ms/page ETA priors.
|
||||||
getCrossMangaPageRateHistorySamples:
|
getCrossMangaPageRateHistorySamples:
|
||||||
SELECT
|
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>
|
suspend fun getHistoryByMangaId(mangaId: Long): List<History>
|
||||||
|
|
||||||
|
fun subscribeToRevision(): Flow<Long>
|
||||||
|
|
||||||
suspend fun getCrossMangaPageRateHistorySamples(
|
suspend fun getCrossMangaPageRateHistorySamples(
|
||||||
excludeMangaId: Long,
|
excludeMangaId: Long,
|
||||||
minDurationMs: 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