From f178a79195b6c68c777f0d5bf2d5c1897aeb47a4 Mon Sep 17 00:00:00 2001 From: littlecodedragon <71352143+AugenAugenAugen@users.noreply.github.com> Date: Thu, 14 May 2026 01:36:28 +0200 Subject: [PATCH] feat(reading-eta): unify library and reader limits; make caps tunable Introduce ReadingEtaPreferences and ReadingEtaLimits so library badges, reader series ETA, cross-manga history mining, and page-rate estimation share one configuration. Add reader settings sliders (EN/DE strings). Register preferences in PreferenceModule and wire GetCrossMangaPageRateHistorySamples. Co-authored-by: Cursor --- .../java/eu/kanade/domain/DomainModule.kt | 2 +- .../settings/screen/SettingsReaderScreen.kt | 52 ++++++++++++++ .../kanade/tachiyomi/di/PreferenceModule.kt | 4 ++ .../ui/library/LibraryScreenModel.kt | 26 ++++--- .../tachiyomi/ui/reader/ReaderViewModel.kt | 70 +++++++++++-------- .../GetCrossMangaPageRateHistorySamples.kt | 9 ++- .../domain/reading/ReadingEtaDefaults.kt | 27 +++++-- .../domain/reading/ReadingEtaLimits.kt | 45 ++++++++++++ .../reading/ReadingEtaPageRateEstimator.kt | 16 +++-- .../reading/service/ReadingEtaPreferences.kt | 52 ++++++++++++++ .../moko-resources/base/strings.xml | 14 ++++ .../commonMain/moko-resources/de/strings.xml | 14 ++++ 12 files changed, 273 insertions(+), 58 deletions(-) create mode 100644 domain/src/main/java/tachiyomi/domain/reading/ReadingEtaLimits.kt create mode 100644 domain/src/main/java/tachiyomi/domain/reading/service/ReadingEtaPreferences.kt diff --git a/app/src/main/java/eu/kanade/domain/DomainModule.kt b/app/src/main/java/eu/kanade/domain/DomainModule.kt index 9b07f98d9..5b2b4aac8 100644 --- a/app/src/main/java/eu/kanade/domain/DomainModule.kt +++ b/app/src/main/java/eu/kanade/domain/DomainModule.kt @@ -177,7 +177,7 @@ class DomainModule : InjektModule { addFactory { RemoveHistory(get()) } addFactory { GetTotalReadDuration(get()) } addFactory { GetReadDurationEntriesByMangaIds(get()) } - addFactory { GetCrossMangaPageRateHistorySamples(get()) } + addFactory { GetCrossMangaPageRateHistorySamples(get(), get()) } addFactory { DeleteDownload(get(), get()) } diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsReaderScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsReaderScreen.kt index c6e96a9ea..da8244f42 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsReaderScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsReaderScreen.kt @@ -17,6 +17,7 @@ import eu.kanade.tachiyomi.util.system.hasDisplayCutout import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.toImmutableMap +import tachiyomi.domain.reading.service.ReadingEtaPreferences import tachiyomi.i18n.MR import tachiyomi.i18n.kmk.KMR import tachiyomi.i18n.sy.SYMR @@ -101,6 +102,7 @@ object SettingsReaderScreen : SearchableSettings { ), SY <-- */ getDisplayGroup(readerPreferences = readerPref), + getReadingEtaTuningGroup(), getEInkGroup(readerPreferences = readerPref), getReadingGroup(readerPreferences = readerPref), getPagedGroup(readerPreferences = readerPref), @@ -177,6 +179,56 @@ object SettingsReaderScreen : SearchableSettings { ) } + @Composable + private fun getReadingEtaTuningGroup(): Preference.PreferenceGroup { + val readingEtaPrefs = remember { Injekt.get() } + val etaMaxStatsPref = readingEtaPrefs.maxStatsDurationMinutes() + val etaMaxTrustedPref = readingEtaPrefs.maxTrustedSessionMinutes() + val etaSamplePref = readingEtaPrefs.sampleChapterLimit() + val etaMaxPagesPref = readingEtaPrefs.maxPagesReadForPageRate() + val etaMaxStats by etaMaxStatsPref.collectAsState() + val etaMaxTrusted by etaMaxTrustedPref.collectAsState() + val etaSample by etaSamplePref.collectAsState() + val etaMaxPages by etaMaxPagesPref.collectAsState() + return Preference.PreferenceGroup( + title = stringResource(KMR.strings.pref_reading_eta_group_title), + preferenceItems = persistentListOf( + Preference.PreferenceItem.SliderPreference( + value = etaMaxStats, + valueRange = 5..180, + title = stringResource(KMR.strings.pref_reading_eta_max_stats_min), + subtitle = stringResource(KMR.strings.pref_reading_eta_max_stats_min_summary), + valueString = stringResource(KMR.strings.pref_reading_eta_max_stats_min_value, etaMaxStats), + onValueChanged = { etaMaxStatsPref.set(it) }, + ), + Preference.PreferenceItem.SliderPreference( + value = etaMaxTrusted, + valueRange = 60..1440, + title = stringResource(KMR.strings.pref_reading_eta_max_trusted_min), + subtitle = stringResource(KMR.strings.pref_reading_eta_max_trusted_min_summary), + valueString = stringResource(KMR.strings.pref_reading_eta_max_trusted_min_value, etaMaxTrusted), + onValueChanged = { etaMaxTrustedPref.set(it) }, + ), + Preference.PreferenceItem.SliderPreference( + value = etaSample, + valueRange = 5..100, + title = stringResource(KMR.strings.pref_reading_eta_sample_chapters), + subtitle = stringResource(KMR.strings.pref_reading_eta_sample_chapters_summary), + valueString = stringResource(KMR.strings.pref_reading_eta_sample_chapters_value, etaSample), + onValueChanged = { etaSamplePref.set(it) }, + ), + Preference.PreferenceItem.SliderPreference( + value = (etaMaxPages / 100).coerceIn(10, 500), + valueRange = 10..500, + title = stringResource(KMR.strings.pref_reading_eta_max_pages_sample), + subtitle = stringResource(KMR.strings.pref_reading_eta_max_pages_sample_summary), + valueString = stringResource(KMR.strings.pref_reading_eta_max_pages_sample_value, etaMaxPages), + onValueChanged = { etaMaxPagesPref.set(it * 100) }, + ), + ), + ) + } + @Composable private fun getEInkGroup(readerPreferences: ReaderPreferences): Preference.PreferenceGroup { val flashPageState by readerPreferences.flashOnPageChange().collectAsState() diff --git a/app/src/main/java/eu/kanade/tachiyomi/di/PreferenceModule.kt b/app/src/main/java/eu/kanade/tachiyomi/di/PreferenceModule.kt index 875e121c2..a4442f794 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/di/PreferenceModule.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/di/PreferenceModule.kt @@ -19,6 +19,7 @@ import tachiyomi.domain.backup.service.BackupPreferences import tachiyomi.domain.download.service.DownloadPreferences import tachiyomi.domain.history.service.HistoryPreferences import tachiyomi.domain.library.service.LibraryPreferences +import tachiyomi.domain.reading.service.ReadingEtaPreferences import tachiyomi.domain.storage.service.StoragePreferences import tachiyomi.domain.updates.service.UpdatesPreferences import uy.kohesive.injekt.api.InjektModule @@ -61,6 +62,9 @@ class PreferenceModule(val app: Application) : InjektModule { addSingletonFactory { ReaderPreferences(get()) } + addSingletonFactory { + ReadingEtaPreferences(get()) + } addSingletonFactory { TrackPreferences(get()) } 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 a26e7761c..e0eac94dd 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,7 +106,8 @@ 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.reading.ReadingEtaLimits +import tachiyomi.domain.reading.service.ReadingEtaPreferences import tachiyomi.domain.manga.interactor.GetIdsOfFavoriteMangaWithMetadata import tachiyomi.domain.manga.interactor.GetLibraryManga import tachiyomi.domain.manga.interactor.GetMergedMangaById @@ -164,6 +165,7 @@ class LibraryScreenModel( // SY <-- // KMK --> private val smartSearchMerge: SmartSearchMerge = Injekt.get(), + private val readingEtaPreferences: ReadingEtaPreferences = Injekt.get(), // KMK <-- ) : StateScreenModel(State()) { @@ -195,8 +197,9 @@ class LibraryScreenModel( ), // KMK <-- getLibraryItemPreferencesFlow(), - ) { (searchQuery, categories, favorites), (tracksMap, trackingFilters), (includedCategories, excludedCategories), itemPreferences -> - val estimatedTimeLeftByMangaId = computeEstimatedTimeLeftByMangaId(favorites) + readingEtaPreferences.limitsFlow(), + ) { (searchQuery, categories, favorites), (tracksMap, trackingFilters), (includedCategories, excludedCategories), itemPreferences, etaLimits -> + val estimatedTimeLeftByMangaId = computeEstimatedTimeLeftByMangaId(favorites, etaLimits) val favoritesWithEstimatedTimeLeft = favorites.map { item -> item.copy( estimatedTimeLeftMillis = if (itemPreferences.estimatedTimeLeftBadge) { @@ -440,6 +443,7 @@ class LibraryScreenModel( private suspend fun computeEstimatedTimeLeftByMangaId( favorites: List, + limits: ReadingEtaLimits, ): Map { if (favorites.isEmpty()) return emptyMap() @@ -448,6 +452,7 @@ class LibraryScreenModel( } val globalAverageChapterDurationMs = calculateAverageChapterDurationMs( chapterDurationsByMangaId.values.flatten(), + limits, ) return favorites.associate { item -> @@ -455,6 +460,7 @@ class LibraryScreenModel( item = item, chapterDurations = chapterDurationsByMangaId[item.id].orEmpty(), globalAverageChapterDurationMs = globalAverageChapterDurationMs, + limits = limits, ) } } @@ -463,11 +469,12 @@ class LibraryScreenModel( item: LibraryItem, chapterDurations: List, globalAverageChapterDurationMs: Long?, + limits: ReadingEtaLimits, ): Long? { val unreadCount = item.libraryManga.unreadCount.coerceAtLeast(0L) if (unreadCount == 0L) return null - val averageChapterDurationMs = calculateAverageChapterDurationMs(chapterDurations) + val averageChapterDurationMs = calculateAverageChapterDurationMs(chapterDurations, limits) ?: globalAverageChapterDurationMs ?: return null @@ -481,11 +488,12 @@ class LibraryScreenModel( private fun calculateAverageChapterDurationMs( chapterDurations: List, + limits: ReadingEtaLimits, ): 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) + .filter { it in limits.minTrackedChapterDurationMs..limits.maxTrackedChapterDurationMs } + .map { it.coerceAtMost(limits.maxChapterDurationMsForStats) } + .takeLast(limits.etaSampleChapterLimit) if (sampledDurations.isEmpty()) return null val sortedDurations = sampledDurations.sorted() @@ -1878,10 +1886,6 @@ class LibraryScreenModel( // KMK --> companion object { - private const val MIN_TRACKED_CHAPTER_DURATION_MS = 2_000L - private const val MAX_TRACKED_CHAPTER_DURATION_MS = 3_600_000L - private const val ETA_SAMPLE_CHAPTER_LIMIT = 60 - /** List of MangaDex UUIDs subject to DMCA takedowns */ @Volatile private var mangaDexDmcaUuids = hashSetOf() 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 02a0cfc75..db66137ee 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 @@ -101,6 +101,8 @@ 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.service.ReadingEtaPreferences import tachiyomi.domain.manga.interactor.GetFlatMetadataById import tachiyomi.domain.manga.interactor.GetManga import tachiyomi.domain.manga.interactor.GetMergedMangaById @@ -137,6 +139,7 @@ class ReaderViewModel @JvmOverloads constructor( private val setMangaViewerFlags: SetMangaViewerFlags = Injekt.get(), private val getIncognitoState: GetIncognitoState = Injekt.get(), private val libraryPreferences: LibraryPreferences = Injekt.get(), + private val readingEtaPreferences: ReadingEtaPreferences = Injekt.get(), // SY --> private val syncPreferences: SyncPreferences = Injekt.get(), private val uiPreferences: UiPreferences = Injekt.get(), @@ -935,16 +938,17 @@ class ReaderViewModel @JvmOverloads constructor( getCurrentChapter()?.let { readerChapter -> if (incognitoMode) return@let + val limits = readingEtaPreferences.currentLimits() val chapterId = readerChapter.chapter.id!! val endTime = Date() val sessionReadDuration = chapterReadStartTime ?.let { SystemClock.elapsedRealtime() - it } - ?.sanitizeTrackedDuration() + ?.sanitizeTrackedDuration(limits) ?: 0 val sessionReadPages = chapterReadStartPageIndex ?.let { startPageIndex -> val endPageIndex = readerChapter.chapter.last_page_read - (endPageIndex - startPageIndex + 1L).sanitizeTrackedPages() + (endPageIndex - startPageIndex + 1L).sanitizeTrackedPages(limits) } ?: 0L @@ -1007,18 +1011,20 @@ class ReaderViewModel @JvmOverloads constructor( val remainingChapters = ( totalChapters - currentChapterIndex - if (isCurrentChapterRead) 1 else 0 ).coerceAtLeast(0) - val chapterDurations = getEtaChapterDurations(currentChapterId) - val chapterPages = getEtaChapterPages(currentChapterId) + 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() - ?.coerceAtMost(ReadingEtaDefaults.MAX_CHAPTER_DURATION_MS_FOR_STATS) + estimateFullChapterMsFromActiveSession(limits) + ?.coerceAtMost(limits.maxChapterDurationMsForStats) ?: calculateProjectedChapterDurationMs( chapterDuration = chapterDurations[currentChapterId] ?: 0L, chapterProgress = currentChapterProgress, + limits = limits, ) val averageChapterDuration = calculateAverageChapterDurationMs( chapterDurations = chapterDurations, @@ -1026,6 +1032,7 @@ class ReaderViewModel @JvmOverloads constructor( currentChapterIndex = currentChapterIndex, isCurrentChapterRead = isCurrentChapterRead, currentChapterProgress = currentChapterProgress, + limits = limits, ) val pageBasedEta = calculatePageBasedEtaMillis( chapterDurations = chapterDurations, @@ -1037,6 +1044,7 @@ class ReaderViewModel @JvmOverloads constructor( totalPages = state.value.totalPages, currentPage = state.value.currentPage, remainingChapters = remainingChapters, + limits = limits, ) val chapterDurationForWholeChapters = averageChapterDuration ?: projectedCurrentChapterDuration val chapterDurationForCurrentChapter = projectedCurrentChapterDuration ?: chapterDurationForWholeChapters @@ -1089,7 +1097,7 @@ class ReaderViewModel @JvmOverloads constructor( } } - private fun getEtaChapterDurations(currentChapterId: Long): Map { + private fun getEtaChapterDurations(currentChapterId: Long, limits: ReadingEtaLimits): Map { val trackedDurations = if (readerPreferences.persistentSeriesEta().get()) { chapterReadDurationsPersistent.toMutableMap() } else { @@ -1098,7 +1106,7 @@ class ReaderViewModel @JvmOverloads constructor( val inProgressDuration = chapterReadStartTime ?.let { SystemClock.elapsedRealtime() - it } - ?.sanitizeTrackedDuration() + ?.sanitizeTrackedDuration(limits) ?: 0L if (inProgressDuration > 0L) { trackedDurations.merge(currentChapterId, inProgressDuration, Long::plus) @@ -1107,7 +1115,7 @@ class ReaderViewModel @JvmOverloads constructor( return trackedDurations } - private fun getEtaChapterPages(currentChapterId: Long): Map { + private fun getEtaChapterPages(currentChapterId: Long, limits: ReadingEtaLimits): Map { val trackedPages = if (readerPreferences.persistentSeriesEta().get()) { chapterReadPagesPersistent.toMutableMap() } else { @@ -1116,7 +1124,7 @@ class ReaderViewModel @JvmOverloads constructor( val inProgressPages = chapterReadStartPageIndex ?.let { startPageIndex -> val currentPageIndex = (state.value.currentPage - 1).coerceAtLeast(startPageIndex) - (currentPageIndex - startPageIndex + 1L).sanitizeTrackedPages() + (currentPageIndex - startPageIndex + 1L).sanitizeTrackedPages(limits) } ?: 0L if (inProgressPages > 0L) { @@ -1135,21 +1143,22 @@ class ReaderViewModel @JvmOverloads constructor( totalPages: Int, currentPage: Int, remainingChapters: Int, + limits: ReadingEtaLimits, ): Long? { val seriesPageRatesMsPerPage = orderedSeriesChapterIds .take(currentChapterIndex + 1) .mapNotNull { chapterId -> - val duration = chapterDurations[chapterId]?.sanitizeTrackedDuration() ?: return@mapNotNull null - val pages = chapterPages[chapterId]?.sanitizeTrackedPages() ?: return@mapNotNull null + 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(ETA_SAMPLE_CHAPTER_LIMIT) + .takeLast(limits.etaSampleChapterLimit) val sampledPages = orderedSeriesChapterIds .take(currentChapterIndex + 1) - .mapNotNull { chapterPages[it]?.sanitizeTrackedPages() } - .takeLast(ETA_SAMPLE_CHAPTER_LIMIT) + .mapNotNull { chapterPages[it]?.sanitizeTrackedPages(limits) } + .takeLast(limits.etaSampleChapterLimit) if (sampledPages.isEmpty()) return null val avgPagesPerChapter = (sampledPages.sum() / sampledPages.size).coerceAtLeast(1L) @@ -1165,6 +1174,7 @@ class ReaderViewModel @JvmOverloads constructor( targetChapterPages = targetChapterPages, seriesPageRatesMsPerPage = seriesPageRatesMsPerPage, crossMangaSamples = crossMangaPageRateSamples, + limits = limits, ) ?: return null val currentRemainingPages = if (isCurrentChapterRead) { @@ -1185,6 +1195,7 @@ class ReaderViewModel @JvmOverloads constructor( currentChapterIndex: Int, isCurrentChapterRead: Boolean, currentChapterProgress: Double?, + limits: ReadingEtaLimits, ): Long? { if (currentChapterIndex < 0) return null val sampledDurations = orderedSeriesChapterIds @@ -1196,15 +1207,16 @@ class ReaderViewModel @JvmOverloads constructor( calculateProjectedChapterDurationMs( chapterDuration = chapterDuration, chapterProgress = currentChapterProgress, + limits = limits, ) } else -> chapterDuration } ?: return@mapNotNull null 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) + .filter { it in limits.minTrackedChapterDurationMs..limits.maxTrackedChapterDurationMs } + .map { it.coerceAtMost(limits.maxChapterDurationMsForStats) } + .takeLast(limits.etaSampleChapterLimit) if (sampledDurations.isEmpty()) return null val sortedDurations = sampledDurations.sorted() @@ -1229,6 +1241,7 @@ class ReaderViewModel @JvmOverloads constructor( private fun calculateProjectedChapterDurationMs( chapterDuration: Long, chapterProgress: Double?, + limits: ReadingEtaLimits, ): Long? { if (chapterDuration <= 0L) return null val progress = chapterProgress @@ -1238,7 +1251,7 @@ class ReaderViewModel @JvmOverloads constructor( if (progress == 0.0) return null return (chapterDuration.toDouble() / progress).toLong() .coerceAtLeast(0L) - .coerceAtMost(ReadingEtaDefaults.MAX_CHAPTER_DURATION_MS_FOR_STATS) + .coerceAtMost(limits.maxChapterDurationMsForStats) } /** @@ -1246,14 +1259,14 @@ class ReaderViewModel @JvmOverloads constructor( * 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? { + 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() - if (sessionMs < MIN_TRACKED_CHAPTER_DURATION_MS) 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) @@ -1271,12 +1284,12 @@ class ReaderViewModel @JvmOverloads constructor( return (duration.toDouble() * remainingProgress).toLong().coerceAtLeast(0L) } - private fun Long.sanitizeTrackedDuration(): Long { - return if (this in MIN_TRACKED_CHAPTER_DURATION_MS..MAX_TRACKED_CHAPTER_DURATION_MS) this else 0L + private fun Long.sanitizeTrackedDuration(limits: ReadingEtaLimits): Long { + return if (this in limits.minTrackedChapterDurationMs..limits.maxTrackedChapterDurationMs) this else 0L } - private fun Long.sanitizeTrackedPages(): Long { - return if (this in MIN_TRACKED_PAGES..MAX_TRACKED_PAGES) 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 } /** @@ -1830,12 +1843,7 @@ class ReaderViewModel @JvmOverloads constructor( } private companion object { - const val MIN_TRACKED_CHAPTER_DURATION_MS = 2_000L - const val MAX_TRACKED_CHAPTER_DURATION_MS = 4 * 60 * 60 * 1_000L - const val ETA_SAMPLE_CHAPTER_LIMIT = 20 const val MIN_PROGRESS_FOR_DURATION_PROJECTION = 0.05 - const val MIN_TRACKED_PAGES = 1L - const val MAX_TRACKED_PAGES = 2_000L } @Immutable diff --git a/domain/src/main/java/tachiyomi/domain/history/interactor/GetCrossMangaPageRateHistorySamples.kt b/domain/src/main/java/tachiyomi/domain/history/interactor/GetCrossMangaPageRateHistorySamples.kt index 8d3967cfc..68f22fc75 100644 --- a/domain/src/main/java/tachiyomi/domain/history/interactor/GetCrossMangaPageRateHistorySamples.kt +++ b/domain/src/main/java/tachiyomi/domain/history/interactor/GetCrossMangaPageRateHistorySamples.kt @@ -3,16 +3,19 @@ package tachiyomi.domain.history.interactor import tachiyomi.domain.history.repository.HistoryRepository import tachiyomi.domain.reading.ReadingEtaDefaults import tachiyomi.domain.reading.ReadingEtaHistorySample +import tachiyomi.domain.reading.service.ReadingEtaPreferences class GetCrossMangaPageRateHistorySamples( private val repository: HistoryRepository, + private val readingEtaPreferences: ReadingEtaPreferences, ) { suspend fun await(excludeMangaId: Long): List { + val limits = readingEtaPreferences.currentLimits() return repository.getCrossMangaPageRateHistorySamples( excludeMangaId = excludeMangaId, - minDurationMs = ReadingEtaDefaults.MIN_CHAPTER_DURATION_MS_FOR_PAGE_RATE, - maxDurationMs = ReadingEtaDefaults.MAX_CHAPTER_DURATION_MS_FOR_PAGE_RATE, - maxPagesRead = ReadingEtaDefaults.MAX_PAGES_READ_FOR_PAGE_RATE, + minDurationMs = limits.minChapterDurationMsForPageRate, + maxDurationMs = limits.maxChapterDurationMsForPageRate, + maxPagesRead = limits.maxPagesReadForPageRate, limit = ReadingEtaDefaults.CROSS_MANGA_PAGE_RATE_SAMPLE_LIMIT, ) } diff --git a/domain/src/main/java/tachiyomi/domain/reading/ReadingEtaDefaults.kt b/domain/src/main/java/tachiyomi/domain/reading/ReadingEtaDefaults.kt index f3a5617be..cd9d174c9 100644 --- a/domain/src/main/java/tachiyomi/domain/reading/ReadingEtaDefaults.kt +++ b/domain/src/main/java/tachiyomi/domain/reading/ReadingEtaDefaults.kt @@ -14,18 +14,35 @@ 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. + * Default for [tachiyomi.domain.reading.service.ReadingEtaPreferences.maxStatsDurationMinutes]. */ const val MAX_CHAPTER_DURATION_MS_FOR_STATS: Long = 45 * 60 * 1000L - /** Same lower bound as reader tracking: ignore ultra-short noise. */ - const val MIN_CHAPTER_DURATION_MS_FOR_PAGE_RATE: Long = 2_000L + /** Ignore ultra-short noise when attributing time to pages (session + history mining). */ + const val MIN_TRACKED_CHAPTER_DURATION_MS: Long = 2_000L - /** Ignore pathological totals when mining history for page-rate priors. */ - const val MAX_CHAPTER_DURATION_MS_FOR_PAGE_RATE: Long = 4L * 60 * 60 * 1000L + /** + * Upper bound on a single session’s credited duration for ETA speed stats. + * Default for [tachiyomi.domain.reading.service.ReadingEtaPreferences.maxTrustedSessionMinutes]. + * Must be ≥ [MAX_CHAPTER_DURATION_MS_FOR_STATS] for sensible filtering. + */ + const val MAX_TRACKED_CHAPTER_DURATION_MS: Long = 4L * 60 * 60 * 1000L + + /** + * How many recent chapters feed duration / page-rate averages (reader + library). + * Default for [tachiyomi.domain.reading.service.ReadingEtaPreferences.sampleChapterLimit]. + */ + const val ETA_SAMPLE_CHAPTER_LIMIT: Int = 60 + + /** Same lower bound as reader tracking: ignore ultra-short noise. */ + const val MIN_CHAPTER_DURATION_MS_FOR_PAGE_RATE: Long = MIN_TRACKED_CHAPTER_DURATION_MS + + /** Default upper bound for mining history for page-rate priors (overridden by trusted session pref). */ + const val MAX_CHAPTER_DURATION_MS_FOR_PAGE_RATE: Long = MAX_TRACKED_CHAPTER_DURATION_MS const val MIN_PAGES_READ_FOR_PAGE_RATE: Long = 1L - /** Match reader sanitization so cross-manga rows stay comparable. */ + /** Default cap on pages per sample for page-rate stats (user-tunable). */ const val MAX_PAGES_READ_FOR_PAGE_RATE: Long = 2_000L /** Recent history rows scanned for cross-series priors (newest first). */ diff --git a/domain/src/main/java/tachiyomi/domain/reading/ReadingEtaLimits.kt b/domain/src/main/java/tachiyomi/domain/reading/ReadingEtaLimits.kt new file mode 100644 index 000000000..ba109b72d --- /dev/null +++ b/domain/src/main/java/tachiyomi/domain/reading/ReadingEtaLimits.kt @@ -0,0 +1,45 @@ +package tachiyomi.domain.reading + +/** + * Effective caps for reading-time estimates, usually built from [tachiyomi.domain.reading.service.ReadingEtaPreferences]. + * Library badge ETA and reader series ETA use the same limits. + */ +data class ReadingEtaLimits( + val maxChapterDurationMsForStats: Long, + val minTrackedChapterDurationMs: Long, + val maxTrackedChapterDurationMs: Long, + val etaSampleChapterLimit: Int, + val minChapterDurationMsForPageRate: Long, + val maxChapterDurationMsForPageRate: Long, + val maxPagesReadForPageRate: Long, +) { + companion object { + val DEFAULT: ReadingEtaLimits = fromRaw( + statsMinutes = (ReadingEtaDefaults.MAX_CHAPTER_DURATION_MS_FOR_STATS / 60_000L).toInt(), + trustedSessionMinutes = (ReadingEtaDefaults.MAX_TRACKED_CHAPTER_DURATION_MS / 60_000L).toInt(), + sampleChapterLimit = ReadingEtaDefaults.ETA_SAMPLE_CHAPTER_LIMIT, + maxPagesSample = ReadingEtaDefaults.MAX_PAGES_READ_FOR_PAGE_RATE.toInt(), + ) + + fun fromRaw( + statsMinutes: Int, + trustedSessionMinutes: Int, + sampleChapterLimit: Int, + maxPagesSample: Int, + ): ReadingEtaLimits { + val statsMin = statsMinutes.coerceIn(5, 24 * 60) + val trustedMin = trustedSessionMinutes.coerceIn(10, 24 * 60) + val statsMs = statsMin * 60_000L + val trustedMs = maxOf(trustedMin * 60_000L, statsMs) + return ReadingEtaLimits( + maxChapterDurationMsForStats = statsMs, + minTrackedChapterDurationMs = ReadingEtaDefaults.MIN_TRACKED_CHAPTER_DURATION_MS, + maxTrackedChapterDurationMs = trustedMs, + etaSampleChapterLimit = sampleChapterLimit.coerceIn(5, 200), + minChapterDurationMsForPageRate = ReadingEtaDefaults.MIN_CHAPTER_DURATION_MS_FOR_PAGE_RATE, + maxChapterDurationMsForPageRate = trustedMs, + maxPagesReadForPageRate = maxPagesSample.coerceIn(100, 50_000).toLong(), + ) + } + } +} diff --git a/domain/src/main/java/tachiyomi/domain/reading/ReadingEtaPageRateEstimator.kt b/domain/src/main/java/tachiyomi/domain/reading/ReadingEtaPageRateEstimator.kt index ba1abda21..7061d945f 100644 --- a/domain/src/main/java/tachiyomi/domain/reading/ReadingEtaPageRateEstimator.kt +++ b/domain/src/main/java/tachiyomi/domain/reading/ReadingEtaPageRateEstimator.kt @@ -15,10 +15,11 @@ object ReadingEtaPageRateEstimator { targetChapterPages: Int, seriesPageRatesMsPerPage: List, crossMangaSamples: List, + limits: ReadingEtaLimits = ReadingEtaLimits.DEFAULT, ): Long? { val n = seriesPageRatesMsPerPage.size val seriesAvg = if (n > 0) seriesPageRatesMsPerPage.average() else null - val prior = estimatePriorMsPerPageFromCrossManga(targetChapterPages, crossMangaSamples) + val prior = estimatePriorMsPerPageFromCrossManga(targetChapterPages, crossMangaSamples, limits) return when { n >= 3 && seriesAvg != null -> seriesAvg.toLong().coerceAtLeast(1L) @@ -35,9 +36,10 @@ object ReadingEtaPageRateEstimator { private fun estimatePriorMsPerPageFromCrossManga( targetChapterPages: Int, samples: List, + limits: ReadingEtaLimits, ): Long? { if (targetChapterPages <= 0) return null - val ratesWithSize = samples.mapNotNull { toRateWithChapterPages(it) } + val ratesWithSize = samples.mapNotNull { toRateWithChapterPages(it, limits) } if (ratesWithSize.isEmpty()) return null val inner = ratesWithinPageBand(ratesWithSize, targetChapterPages, ReadingEtaDefaults.SIMILAR_CHAPTER_PAGE_COUNT_RATIO) @@ -49,16 +51,16 @@ object ReadingEtaPageRateEstimator { return medianMsPerPage(ratesWithSize.map { it.first }) } - private fun toRateWithChapterPages(sample: ReadingEtaHistorySample): Pair? { + private fun toRateWithChapterPages(sample: ReadingEtaHistorySample, limits: ReadingEtaLimits): Pair? { if (!sample.chapterFullyRead) return null val chapterPages = (sample.lastPageReadIndex + 1L).toInt().coerceAtLeast(1) if (sample.pagesRead < ReadingEtaDefaults.MIN_PAGES_READ_FOR_PAGE_RATE) return null - if (sample.pagesRead > ReadingEtaDefaults.MAX_PAGES_READ_FOR_PAGE_RATE) return null - if (sample.readDurationMs < ReadingEtaDefaults.MIN_CHAPTER_DURATION_MS_FOR_PAGE_RATE) return null - if (sample.readDurationMs > ReadingEtaDefaults.MAX_CHAPTER_DURATION_MS_FOR_PAGE_RATE) return null + if (sample.pagesRead > limits.maxPagesReadForPageRate) return null + if (sample.readDurationMs < limits.minChapterDurationMsForPageRate) return null + if (sample.readDurationMs > limits.maxChapterDurationMsForPageRate) return null if (sample.pagesRead > chapterPages * 2L) return null - val capped = sample.readDurationMs.coerceAtMost(ReadingEtaDefaults.MAX_CHAPTER_DURATION_MS_FOR_STATS) + val capped = sample.readDurationMs.coerceAtMost(limits.maxChapterDurationMsForStats) val msPerPage = capped.toDouble() / sample.pagesRead.toDouble() if (!msPerPage.isFinite() || msPerPage <= 0.0) return null return msPerPage to chapterPages diff --git a/domain/src/main/java/tachiyomi/domain/reading/service/ReadingEtaPreferences.kt b/domain/src/main/java/tachiyomi/domain/reading/service/ReadingEtaPreferences.kt new file mode 100644 index 000000000..962c8695e --- /dev/null +++ b/domain/src/main/java/tachiyomi/domain/reading/service/ReadingEtaPreferences.kt @@ -0,0 +1,52 @@ +package tachiyomi.domain.reading.service + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import tachiyomi.core.common.preference.PreferenceStore +import tachiyomi.domain.reading.ReadingEtaDefaults +import tachiyomi.domain.reading.ReadingEtaLimits + +/** + * User-tunable bounds shared by library time-left badges and reader series ETA. + */ +class ReadingEtaPreferences( + private val preferenceStore: PreferenceStore, +) { + + fun maxStatsDurationMinutes() = preferenceStore.getInt( + "pref_reading_eta_max_stats_min", + (ReadingEtaDefaults.MAX_CHAPTER_DURATION_MS_FOR_STATS / 60_000L).toInt(), + ) + + fun maxTrustedSessionMinutes() = preferenceStore.getInt( + "pref_reading_eta_max_trusted_session_min", + (ReadingEtaDefaults.MAX_TRACKED_CHAPTER_DURATION_MS / 60_000L).toInt(), + ) + + fun sampleChapterLimit() = preferenceStore.getInt( + "pref_reading_eta_sample_chapter_limit", + ReadingEtaDefaults.ETA_SAMPLE_CHAPTER_LIMIT, + ) + + fun maxPagesReadForPageRate() = preferenceStore.getInt( + "pref_reading_eta_max_pages_sample", + ReadingEtaDefaults.MAX_PAGES_READ_FOR_PAGE_RATE.toInt(), + ) + + fun currentLimits(): ReadingEtaLimits = ReadingEtaLimits.fromRaw( + statsMinutes = maxStatsDurationMinutes().get(), + trustedSessionMinutes = maxTrustedSessionMinutes().get(), + sampleChapterLimit = sampleChapterLimit().get(), + maxPagesSample = maxPagesReadForPageRate().get(), + ) + + fun limitsFlow(): Flow = combine( + maxStatsDurationMinutes().changes(), + maxTrustedSessionMinutes().changes(), + sampleChapterLimit().changes(), + maxPagesReadForPageRate().changes(), + ) { statsMin, trustedMin, samples, maxPages -> + ReadingEtaLimits.fromRaw(statsMin, trustedMin, samples, maxPages) + }.distinctUntilChanged() +} diff --git a/i18n-kmk/src/commonMain/moko-resources/base/strings.xml b/i18n-kmk/src/commonMain/moko-resources/base/strings.xml index 4bfb09902..b94002b31 100644 --- a/i18n-kmk/src/commonMain/moko-resources/base/strings.xml +++ b/i18n-kmk/src/commonMain/moko-resources/base/strings.xml @@ -238,4 +238,18 @@ Displays a button to download the app Show Discord Button Displays a button to join the Discord server + + Time estimate tuning + Max minutes per chapter sample + When averaging read times, each chapter session counts at most this many minutes. Prevents one long idle tab from skewing estimates. + %d min + Max trusted session length + Sessions longer than this are ignored for speed stats and when loading cross-series page-rate hints. Must be at least the value above. + %d min + Chapters sampled for averages + How many recent chapters feed library time-left badges and reader series ETA averages. + %d chapters + Max pages per history sample + History rows with more pages read than this are skipped for page-rate estimates. + %d pages diff --git a/i18n-kmk/src/commonMain/moko-resources/de/strings.xml b/i18n-kmk/src/commonMain/moko-resources/de/strings.xml index ea143a52c..7da0a0930 100644 --- a/i18n-kmk/src/commonMain/moko-resources/de/strings.xml +++ b/i18n-kmk/src/commonMain/moko-resources/de/strings.xml @@ -192,4 +192,18 @@ 🟢 Online %1$s %2$s Discord Rich Presence + + Zeitschätzungen (Bibliothek & Reader) + Höchstwert Minuten pro Kapitel (Mittelwerte) + Bei Durchschnittszeiten zählt jede Lesesession pro Kapitel höchstens so viele Minuten. Verhindert, dass ein offenes Tab die Schätzung verzerrt. + %d Min. + Max. vertrauenswürdige Sitzungslänge + Längere Sessions fließen nicht in Geschwindigkeitsstatistik und seitenbasierte Fremdserien-Hinweise ein. Muss mindestens dem Wert darüber entsprechen. + %d Min. + Kapitel für Mittelwerte + So viele der zuletzt gelesenen Kapitel fließen in Bibliotheks-Badges und Reader-Restzeit ein. + %d Kapitel + Max. Seiten pro Verlauf-Stichprobe + Verlaufseinträge mit mehr gelesenen Seiten werden für Seitenraten-Schätzungen übersprungen. + %d Seiten