diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt index 0aa43e980..a26e7761c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt @@ -106,6 +106,7 @@ import tachiyomi.domain.library.model.LibraryManga import tachiyomi.domain.library.model.LibrarySort import tachiyomi.domain.library.model.sort import tachiyomi.domain.library.service.LibraryPreferences +import tachiyomi.domain.reading.ReadingEtaDefaults import tachiyomi.domain.manga.interactor.GetIdsOfFavoriteMangaWithMetadata import tachiyomi.domain.manga.interactor.GetLibraryManga import tachiyomi.domain.manga.interactor.GetMergedMangaById @@ -483,6 +484,7 @@ class LibraryScreenModel( ): Long? { val sampledDurations = chapterDurations .filter { it in MIN_TRACKED_CHAPTER_DURATION_MS..MAX_TRACKED_CHAPTER_DURATION_MS } + .map { it.coerceAtMost(ReadingEtaDefaults.MAX_CHAPTER_DURATION_MS_FOR_STATS) } .takeLast(ETA_SAMPLE_CHAPTER_LIMIT) if (sampledDurations.isEmpty()) return null diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt index a66f0d96b..23684c3e2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt @@ -97,6 +97,7 @@ import tachiyomi.domain.history.interactor.GetNextChapters import tachiyomi.domain.history.interactor.UpsertHistory import tachiyomi.domain.history.model.HistoryUpdate import tachiyomi.domain.library.service.LibraryPreferences +import tachiyomi.domain.reading.ReadingEtaDefaults import tachiyomi.domain.manga.interactor.GetFlatMetadataById import tachiyomi.domain.manga.interactor.GetManga import tachiyomi.domain.manga.interactor.GetMergedMangaById @@ -1001,10 +1002,13 @@ class ReaderViewModel @JvmOverloads constructor( currentPage = state.value.currentPage, totalPages = state.value.totalPages, ) - val projectedCurrentChapterDuration = calculateProjectedChapterDurationMs( - chapterDuration = chapterDurations[currentChapterId] ?: 0L, - chapterProgress = currentChapterProgress, - ) + val projectedCurrentChapterDuration = + estimateFullChapterMsFromActiveSession() + ?.coerceAtMost(ReadingEtaDefaults.MAX_CHAPTER_DURATION_MS_FOR_STATS) + ?: calculateProjectedChapterDurationMs( + chapterDuration = chapterDurations[currentChapterId] ?: 0L, + chapterProgress = currentChapterProgress, + ) val averageChapterDuration = calculateAverageChapterDurationMs( chapterDurations = chapterDurations, currentChapterId = currentChapterId, @@ -1131,7 +1135,10 @@ class ReaderViewModel @JvmOverloads constructor( } .takeLast(ETA_SAMPLE_CHAPTER_LIMIT) if (pageRates.isEmpty()) return null - val avgMsPerPage = pageRates.average().toLong().coerceAtLeast(1L) + var avgMsPerPage = pageRates.average().toLong().coerceAtLeast(1L) + if (pageRates.size < ReadingEtaDefaults.MIN_TRUSTED_PAGE_RATE_SAMPLES) { + avgMsPerPage = maxOf(avgMsPerPage, ReadingEtaDefaults.FALLBACK_MS_PER_PAGE) + } val sampledPages = orderedSeriesChapterIds .take(currentChapterIndex + 1) @@ -1176,6 +1183,7 @@ class ReaderViewModel @JvmOverloads constructor( normalizedDuration } .filter { it in MIN_TRACKED_CHAPTER_DURATION_MS..MAX_TRACKED_CHAPTER_DURATION_MS } + .map { it.coerceAtMost(ReadingEtaDefaults.MAX_CHAPTER_DURATION_MS_FOR_STATS) } .takeLast(ETA_SAMPLE_CHAPTER_LIMIT) if (sampledDurations.isEmpty()) return null @@ -1208,7 +1216,30 @@ class ReaderViewModel @JvmOverloads constructor( ?.coerceIn(0.0, 1.0) ?: return null if (progress == 0.0) return null - return (chapterDuration.toDouble() / progress).toLong().coerceAtLeast(0L) + return (chapterDuration.toDouble() / progress).toLong() + .coerceAtLeast(0L) + .coerceAtMost(ReadingEtaDefaults.MAX_CHAPTER_DURATION_MS_FOR_STATS) + } + + /** + * 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(): 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() + if (sessionMs < MIN_TRACKED_CHAPTER_DURATION_MS) 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( diff --git a/domain/src/main/java/tachiyomi/domain/reading/ReadingEtaDefaults.kt b/domain/src/main/java/tachiyomi/domain/reading/ReadingEtaDefaults.kt new file mode 100644 index 000000000..8e0bf7c47 --- /dev/null +++ b/domain/src/main/java/tachiyomi/domain/reading/ReadingEtaDefaults.kt @@ -0,0 +1,23 @@ +package tachiyomi.domain.reading + +/** + * Shared defaults for "time left" / reading-speed estimates in the library and reader. + * These cap idle-inflated history and avoid ultra-optimistic page rates from skimming + * the first few pages. + */ +object ReadingEtaDefaults { + + /** + * When building per-chapter averages, treat each sample as at most this long. + * Stops a single tab-left-open session from blowing up series-wide ETAs. + */ + const val MAX_CHAPTER_DURATION_MS_FOR_STATS: Long = 45 * 60 * 1000L + + /** + * If we have few per-chapter page-rate samples, do not go below this ms/page + * (avoids "2 minutes for the whole series" after a quick flip through ch.1). + */ + const val FALLBACK_MS_PER_PAGE: Long = 12_000L + + const val MIN_TRUSTED_PAGE_RATE_SAMPLES: Int = 3 +}