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 <cursoragent@cursor.com>
This commit is contained in:
parent
e0f4262407
commit
0f6d0e1656
12 changed files with 273 additions and 58 deletions
|
|
@ -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()) }
|
||||
|
||||
|
|
|
|||
|
|
@ -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<ReadingEtaPreferences>() }
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<LibraryScreenModel.State>(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<LibraryItem>,
|
||||
limits: ReadingEtaLimits,
|
||||
): Map<Long, Long?> {
|
||||
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<Long>,
|
||||
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<Long>,
|
||||
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<String>()
|
||||
|
|
|
|||
|
|
@ -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<Long, Long> {
|
||||
private fun getEtaChapterDurations(currentChapterId: Long, limits: ReadingEtaLimits): Map<Long, Long> {
|
||||
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<Long, Long> {
|
||||
private fun getEtaChapterPages(currentChapterId: Long, limits: ReadingEtaLimits): Map<Long, Long> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<ReadingEtaHistorySample> {
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -15,10 +15,11 @@ object ReadingEtaPageRateEstimator {
|
|||
targetChapterPages: Int,
|
||||
seriesPageRatesMsPerPage: List<Double>,
|
||||
crossMangaSamples: List<ReadingEtaHistorySample>,
|
||||
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<ReadingEtaHistorySample>,
|
||||
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<Double, Int>? {
|
||||
private fun toRateWithChapterPages(sample: ReadingEtaHistorySample, limits: ReadingEtaLimits): Pair<Double, Int>? {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<ReadingEtaLimits> = combine(
|
||||
maxStatsDurationMinutes().changes(),
|
||||
maxTrustedSessionMinutes().changes(),
|
||||
sampleChapterLimit().changes(),
|
||||
maxPagesReadForPageRate().changes(),
|
||||
) { statsMin, trustedMin, samples, maxPages ->
|
||||
ReadingEtaLimits.fromRaw(statsMin, trustedMin, samples, maxPages)
|
||||
}.distinctUntilChanged()
|
||||
}
|
||||
|
|
@ -238,4 +238,18 @@
|
|||
<string name="pref_discord_show_download_button_summary">Displays a button to download the app</string>
|
||||
<string name="pref_discord_show_discord_button">Show Discord Button</string>
|
||||
<string name="pref_discord_show_discord_button_summary">Displays a button to join the Discord server</string>
|
||||
<!-- Reading ETA (library + reader) -->
|
||||
<string name="pref_reading_eta_group_title">Time estimate tuning</string>
|
||||
<string name="pref_reading_eta_max_stats_min">Max minutes per chapter sample</string>
|
||||
<string name="pref_reading_eta_max_stats_min_summary">When averaging read times, each chapter session counts at most this many minutes. Prevents one long idle tab from skewing estimates.</string>
|
||||
<string name="pref_reading_eta_max_stats_min_value">%d min</string>
|
||||
<string name="pref_reading_eta_max_trusted_min">Max trusted session length</string>
|
||||
<string name="pref_reading_eta_max_trusted_min_summary">Sessions longer than this are ignored for speed stats and when loading cross-series page-rate hints. Must be at least the value above.</string>
|
||||
<string name="pref_reading_eta_max_trusted_min_value">%d min</string>
|
||||
<string name="pref_reading_eta_sample_chapters">Chapters sampled for averages</string>
|
||||
<string name="pref_reading_eta_sample_chapters_summary">How many recent chapters feed library time-left badges and reader series ETA averages.</string>
|
||||
<string name="pref_reading_eta_sample_chapters_value">%d chapters</string>
|
||||
<string name="pref_reading_eta_max_pages_sample">Max pages per history sample</string>
|
||||
<string name="pref_reading_eta_max_pages_sample_summary">History rows with more pages read than this are skipped for page-rate estimates.</string>
|
||||
<string name="pref_reading_eta_max_pages_sample_value">%d pages</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -192,4 +192,18 @@
|
|||
<string name="pref_discord_online">🟢 Online</string>
|
||||
<string name="discord_status_description">%1$s %2$s</string>
|
||||
<string name="pref_discord_rpc">Discord Rich Presence</string>
|
||||
<!-- Reading ETA -->
|
||||
<string name="pref_reading_eta_group_title">Zeitschätzungen (Bibliothek & Reader)</string>
|
||||
<string name="pref_reading_eta_max_stats_min">Höchstwert Minuten pro Kapitel (Mittelwerte)</string>
|
||||
<string name="pref_reading_eta_max_stats_min_summary">Bei Durchschnittszeiten zählt jede Lesesession pro Kapitel höchstens so viele Minuten. Verhindert, dass ein offenes Tab die Schätzung verzerrt.</string>
|
||||
<string name="pref_reading_eta_max_stats_min_value">%d Min.</string>
|
||||
<string name="pref_reading_eta_max_trusted_min">Max. vertrauenswürdige Sitzungslänge</string>
|
||||
<string name="pref_reading_eta_max_trusted_min_summary">Längere Sessions fließen nicht in Geschwindigkeitsstatistik und seitenbasierte Fremdserien-Hinweise ein. Muss mindestens dem Wert darüber entsprechen.</string>
|
||||
<string name="pref_reading_eta_max_trusted_min_value">%d Min.</string>
|
||||
<string name="pref_reading_eta_sample_chapters">Kapitel für Mittelwerte</string>
|
||||
<string name="pref_reading_eta_sample_chapters_summary">So viele der zuletzt gelesenen Kapitel fließen in Bibliotheks-Badges und Reader-Restzeit ein.</string>
|
||||
<string name="pref_reading_eta_sample_chapters_value">%d Kapitel</string>
|
||||
<string name="pref_reading_eta_max_pages_sample">Max. Seiten pro Verlauf-Stichprobe</string>
|
||||
<string name="pref_reading_eta_max_pages_sample_summary">Verlaufseinträge mit mehr gelesenen Seiten werden für Seitenraten-Schätzungen übersprungen.</string>
|
||||
<string name="pref_reading_eta_max_pages_sample_value">%d Seiten</string>
|
||||
</resources>
|
||||
|
|
|
|||
Loading…
Reference in a new issue