feat(reading-eta): unify library and reader limits; make caps tunable
All checks were successful
Forgejo Release Builder / release (push) Successful in 5m17s
All checks were successful
Forgejo Release Builder / release (push) Successful in 5m17s
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
f178a79195
12 changed files with 273 additions and 58 deletions
|
|
@ -177,7 +177,7 @@ class DomainModule : InjektModule {
|
||||||
addFactory { RemoveHistory(get()) }
|
addFactory { RemoveHistory(get()) }
|
||||||
addFactory { GetTotalReadDuration(get()) }
|
addFactory { GetTotalReadDuration(get()) }
|
||||||
addFactory { GetReadDurationEntriesByMangaIds(get()) }
|
addFactory { GetReadDurationEntriesByMangaIds(get()) }
|
||||||
addFactory { GetCrossMangaPageRateHistorySamples(get()) }
|
addFactory { GetCrossMangaPageRateHistorySamples(get(), get()) }
|
||||||
|
|
||||||
addFactory { DeleteDownload(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.persistentListOf
|
||||||
import kotlinx.collections.immutable.persistentMapOf
|
import kotlinx.collections.immutable.persistentMapOf
|
||||||
import kotlinx.collections.immutable.toImmutableMap
|
import kotlinx.collections.immutable.toImmutableMap
|
||||||
|
import tachiyomi.domain.reading.service.ReadingEtaPreferences
|
||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
import tachiyomi.i18n.kmk.KMR
|
import tachiyomi.i18n.kmk.KMR
|
||||||
import tachiyomi.i18n.sy.SYMR
|
import tachiyomi.i18n.sy.SYMR
|
||||||
|
|
@ -101,6 +102,7 @@ object SettingsReaderScreen : SearchableSettings {
|
||||||
),
|
),
|
||||||
SY <-- */
|
SY <-- */
|
||||||
getDisplayGroup(readerPreferences = readerPref),
|
getDisplayGroup(readerPreferences = readerPref),
|
||||||
|
getReadingEtaTuningGroup(),
|
||||||
getEInkGroup(readerPreferences = readerPref),
|
getEInkGroup(readerPreferences = readerPref),
|
||||||
getReadingGroup(readerPreferences = readerPref),
|
getReadingGroup(readerPreferences = readerPref),
|
||||||
getPagedGroup(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
|
@Composable
|
||||||
private fun getEInkGroup(readerPreferences: ReaderPreferences): Preference.PreferenceGroup {
|
private fun getEInkGroup(readerPreferences: ReaderPreferences): Preference.PreferenceGroup {
|
||||||
val flashPageState by readerPreferences.flashOnPageChange().collectAsState()
|
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.download.service.DownloadPreferences
|
||||||
import tachiyomi.domain.history.service.HistoryPreferences
|
import tachiyomi.domain.history.service.HistoryPreferences
|
||||||
import tachiyomi.domain.library.service.LibraryPreferences
|
import tachiyomi.domain.library.service.LibraryPreferences
|
||||||
|
import tachiyomi.domain.reading.service.ReadingEtaPreferences
|
||||||
import tachiyomi.domain.storage.service.StoragePreferences
|
import tachiyomi.domain.storage.service.StoragePreferences
|
||||||
import tachiyomi.domain.updates.service.UpdatesPreferences
|
import tachiyomi.domain.updates.service.UpdatesPreferences
|
||||||
import uy.kohesive.injekt.api.InjektModule
|
import uy.kohesive.injekt.api.InjektModule
|
||||||
|
|
@ -61,6 +62,9 @@ class PreferenceModule(val app: Application) : InjektModule {
|
||||||
addSingletonFactory {
|
addSingletonFactory {
|
||||||
ReaderPreferences(get())
|
ReaderPreferences(get())
|
||||||
}
|
}
|
||||||
|
addSingletonFactory {
|
||||||
|
ReadingEtaPreferences(get())
|
||||||
|
}
|
||||||
addSingletonFactory {
|
addSingletonFactory {
|
||||||
TrackPreferences(get())
|
TrackPreferences(get())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -106,7 +106,8 @@ import tachiyomi.domain.library.model.LibraryManga
|
||||||
import tachiyomi.domain.library.model.LibrarySort
|
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.ReadingEtaDefaults
|
import tachiyomi.domain.reading.ReadingEtaLimits
|
||||||
|
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
|
||||||
import tachiyomi.domain.manga.interactor.GetMergedMangaById
|
import tachiyomi.domain.manga.interactor.GetMergedMangaById
|
||||||
|
|
@ -164,6 +165,7 @@ class LibraryScreenModel(
|
||||||
// SY <--
|
// SY <--
|
||||||
// KMK -->
|
// KMK -->
|
||||||
private val smartSearchMerge: SmartSearchMerge = Injekt.get(),
|
private val smartSearchMerge: SmartSearchMerge = Injekt.get(),
|
||||||
|
private val readingEtaPreferences: ReadingEtaPreferences = Injekt.get(),
|
||||||
// KMK <--
|
// KMK <--
|
||||||
) : StateScreenModel<LibraryScreenModel.State>(State()) {
|
) : StateScreenModel<LibraryScreenModel.State>(State()) {
|
||||||
|
|
||||||
|
|
@ -195,8 +197,9 @@ class LibraryScreenModel(
|
||||||
),
|
),
|
||||||
// KMK <--
|
// KMK <--
|
||||||
getLibraryItemPreferencesFlow(),
|
getLibraryItemPreferencesFlow(),
|
||||||
) { (searchQuery, categories, favorites), (tracksMap, trackingFilters), (includedCategories, excludedCategories), itemPreferences ->
|
readingEtaPreferences.limitsFlow(),
|
||||||
val estimatedTimeLeftByMangaId = computeEstimatedTimeLeftByMangaId(favorites)
|
) { (searchQuery, categories, favorites), (tracksMap, trackingFilters), (includedCategories, excludedCategories), itemPreferences, etaLimits ->
|
||||||
|
val estimatedTimeLeftByMangaId = computeEstimatedTimeLeftByMangaId(favorites, etaLimits)
|
||||||
val favoritesWithEstimatedTimeLeft = favorites.map { item ->
|
val favoritesWithEstimatedTimeLeft = favorites.map { item ->
|
||||||
item.copy(
|
item.copy(
|
||||||
estimatedTimeLeftMillis = if (itemPreferences.estimatedTimeLeftBadge) {
|
estimatedTimeLeftMillis = if (itemPreferences.estimatedTimeLeftBadge) {
|
||||||
|
|
@ -440,6 +443,7 @@ class LibraryScreenModel(
|
||||||
|
|
||||||
private suspend fun computeEstimatedTimeLeftByMangaId(
|
private suspend fun computeEstimatedTimeLeftByMangaId(
|
||||||
favorites: List<LibraryItem>,
|
favorites: List<LibraryItem>,
|
||||||
|
limits: ReadingEtaLimits,
|
||||||
): Map<Long, Long?> {
|
): Map<Long, Long?> {
|
||||||
if (favorites.isEmpty()) return emptyMap()
|
if (favorites.isEmpty()) return emptyMap()
|
||||||
|
|
||||||
|
|
@ -448,6 +452,7 @@ class LibraryScreenModel(
|
||||||
}
|
}
|
||||||
val globalAverageChapterDurationMs = calculateAverageChapterDurationMs(
|
val globalAverageChapterDurationMs = calculateAverageChapterDurationMs(
|
||||||
chapterDurationsByMangaId.values.flatten(),
|
chapterDurationsByMangaId.values.flatten(),
|
||||||
|
limits,
|
||||||
)
|
)
|
||||||
|
|
||||||
return favorites.associate { item ->
|
return favorites.associate { item ->
|
||||||
|
|
@ -455,6 +460,7 @@ class LibraryScreenModel(
|
||||||
item = item,
|
item = item,
|
||||||
chapterDurations = chapterDurationsByMangaId[item.id].orEmpty(),
|
chapterDurations = chapterDurationsByMangaId[item.id].orEmpty(),
|
||||||
globalAverageChapterDurationMs = globalAverageChapterDurationMs,
|
globalAverageChapterDurationMs = globalAverageChapterDurationMs,
|
||||||
|
limits = limits,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -463,11 +469,12 @@ class LibraryScreenModel(
|
||||||
item: LibraryItem,
|
item: LibraryItem,
|
||||||
chapterDurations: List<Long>,
|
chapterDurations: List<Long>,
|
||||||
globalAverageChapterDurationMs: Long?,
|
globalAverageChapterDurationMs: Long?,
|
||||||
|
limits: ReadingEtaLimits,
|
||||||
): Long? {
|
): Long? {
|
||||||
val unreadCount = item.libraryManga.unreadCount.coerceAtLeast(0L)
|
val unreadCount = item.libraryManga.unreadCount.coerceAtLeast(0L)
|
||||||
if (unreadCount == 0L) return null
|
if (unreadCount == 0L) return null
|
||||||
|
|
||||||
val averageChapterDurationMs = calculateAverageChapterDurationMs(chapterDurations)
|
val averageChapterDurationMs = calculateAverageChapterDurationMs(chapterDurations, limits)
|
||||||
?: globalAverageChapterDurationMs
|
?: globalAverageChapterDurationMs
|
||||||
?: return null
|
?: return null
|
||||||
|
|
||||||
|
|
@ -481,11 +488,12 @@ class LibraryScreenModel(
|
||||||
|
|
||||||
private fun calculateAverageChapterDurationMs(
|
private fun calculateAverageChapterDurationMs(
|
||||||
chapterDurations: List<Long>,
|
chapterDurations: List<Long>,
|
||||||
|
limits: ReadingEtaLimits,
|
||||||
): Long? {
|
): Long? {
|
||||||
val sampledDurations = chapterDurations
|
val sampledDurations = chapterDurations
|
||||||
.filter { it in MIN_TRACKED_CHAPTER_DURATION_MS..MAX_TRACKED_CHAPTER_DURATION_MS }
|
.filter { it in limits.minTrackedChapterDurationMs..limits.maxTrackedChapterDurationMs }
|
||||||
.map { it.coerceAtMost(ReadingEtaDefaults.MAX_CHAPTER_DURATION_MS_FOR_STATS) }
|
.map { it.coerceAtMost(limits.maxChapterDurationMsForStats) }
|
||||||
.takeLast(ETA_SAMPLE_CHAPTER_LIMIT)
|
.takeLast(limits.etaSampleChapterLimit)
|
||||||
if (sampledDurations.isEmpty()) return null
|
if (sampledDurations.isEmpty()) return null
|
||||||
|
|
||||||
val sortedDurations = sampledDurations.sorted()
|
val sortedDurations = sampledDurations.sorted()
|
||||||
|
|
@ -1878,10 +1886,6 @@ class LibraryScreenModel(
|
||||||
|
|
||||||
// KMK -->
|
// KMK -->
|
||||||
companion object {
|
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 */
|
/** List of MangaDex UUIDs subject to DMCA takedowns */
|
||||||
@Volatile
|
@Volatile
|
||||||
private var mangaDexDmcaUuids = hashSetOf<String>()
|
private var mangaDexDmcaUuids = hashSetOf<String>()
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,8 @@ 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.ReadingEtaPageRateEstimator
|
||||||
import tachiyomi.domain.reading.ReadingEtaDefaults
|
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.GetFlatMetadataById
|
||||||
import tachiyomi.domain.manga.interactor.GetManga
|
import tachiyomi.domain.manga.interactor.GetManga
|
||||||
import tachiyomi.domain.manga.interactor.GetMergedMangaById
|
import tachiyomi.domain.manga.interactor.GetMergedMangaById
|
||||||
|
|
@ -137,6 +139,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||||
private val setMangaViewerFlags: SetMangaViewerFlags = Injekt.get(),
|
private val setMangaViewerFlags: SetMangaViewerFlags = Injekt.get(),
|
||||||
private val getIncognitoState: GetIncognitoState = Injekt.get(),
|
private val getIncognitoState: GetIncognitoState = Injekt.get(),
|
||||||
private val libraryPreferences: LibraryPreferences = Injekt.get(),
|
private val libraryPreferences: LibraryPreferences = Injekt.get(),
|
||||||
|
private val readingEtaPreferences: ReadingEtaPreferences = Injekt.get(),
|
||||||
// SY -->
|
// SY -->
|
||||||
private val syncPreferences: SyncPreferences = Injekt.get(),
|
private val syncPreferences: SyncPreferences = Injekt.get(),
|
||||||
private val uiPreferences: UiPreferences = Injekt.get(),
|
private val uiPreferences: UiPreferences = Injekt.get(),
|
||||||
|
|
@ -935,16 +938,17 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||||
getCurrentChapter()?.let { readerChapter ->
|
getCurrentChapter()?.let { readerChapter ->
|
||||||
if (incognitoMode) return@let
|
if (incognitoMode) return@let
|
||||||
|
|
||||||
|
val limits = readingEtaPreferences.currentLimits()
|
||||||
val chapterId = readerChapter.chapter.id!!
|
val chapterId = readerChapter.chapter.id!!
|
||||||
val endTime = Date()
|
val endTime = Date()
|
||||||
val sessionReadDuration = chapterReadStartTime
|
val sessionReadDuration = chapterReadStartTime
|
||||||
?.let { SystemClock.elapsedRealtime() - it }
|
?.let { SystemClock.elapsedRealtime() - it }
|
||||||
?.sanitizeTrackedDuration()
|
?.sanitizeTrackedDuration(limits)
|
||||||
?: 0
|
?: 0
|
||||||
val sessionReadPages = chapterReadStartPageIndex
|
val sessionReadPages = chapterReadStartPageIndex
|
||||||
?.let { startPageIndex ->
|
?.let { startPageIndex ->
|
||||||
val endPageIndex = readerChapter.chapter.last_page_read
|
val endPageIndex = readerChapter.chapter.last_page_read
|
||||||
(endPageIndex - startPageIndex + 1L).sanitizeTrackedPages()
|
(endPageIndex - startPageIndex + 1L).sanitizeTrackedPages(limits)
|
||||||
}
|
}
|
||||||
?: 0L
|
?: 0L
|
||||||
|
|
||||||
|
|
@ -1007,18 +1011,20 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||||
val remainingChapters = (
|
val remainingChapters = (
|
||||||
totalChapters - currentChapterIndex - if (isCurrentChapterRead) 1 else 0
|
totalChapters - currentChapterIndex - if (isCurrentChapterRead) 1 else 0
|
||||||
).coerceAtLeast(0)
|
).coerceAtLeast(0)
|
||||||
val chapterDurations = getEtaChapterDurations(currentChapterId)
|
val limits = readingEtaPreferences.currentLimits()
|
||||||
val chapterPages = getEtaChapterPages(currentChapterId)
|
val chapterDurations = getEtaChapterDurations(currentChapterId, limits)
|
||||||
|
val chapterPages = getEtaChapterPages(currentChapterId, limits)
|
||||||
val currentChapterProgress = calculateChapterProgress(
|
val currentChapterProgress = calculateChapterProgress(
|
||||||
currentPage = state.value.currentPage,
|
currentPage = state.value.currentPage,
|
||||||
totalPages = state.value.totalPages,
|
totalPages = state.value.totalPages,
|
||||||
)
|
)
|
||||||
val projectedCurrentChapterDuration =
|
val projectedCurrentChapterDuration =
|
||||||
estimateFullChapterMsFromActiveSession()
|
estimateFullChapterMsFromActiveSession(limits)
|
||||||
?.coerceAtMost(ReadingEtaDefaults.MAX_CHAPTER_DURATION_MS_FOR_STATS)
|
?.coerceAtMost(limits.maxChapterDurationMsForStats)
|
||||||
?: calculateProjectedChapterDurationMs(
|
?: calculateProjectedChapterDurationMs(
|
||||||
chapterDuration = chapterDurations[currentChapterId] ?: 0L,
|
chapterDuration = chapterDurations[currentChapterId] ?: 0L,
|
||||||
chapterProgress = currentChapterProgress,
|
chapterProgress = currentChapterProgress,
|
||||||
|
limits = limits,
|
||||||
)
|
)
|
||||||
val averageChapterDuration = calculateAverageChapterDurationMs(
|
val averageChapterDuration = calculateAverageChapterDurationMs(
|
||||||
chapterDurations = chapterDurations,
|
chapterDurations = chapterDurations,
|
||||||
|
|
@ -1026,6 +1032,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||||
currentChapterIndex = currentChapterIndex,
|
currentChapterIndex = currentChapterIndex,
|
||||||
isCurrentChapterRead = isCurrentChapterRead,
|
isCurrentChapterRead = isCurrentChapterRead,
|
||||||
currentChapterProgress = currentChapterProgress,
|
currentChapterProgress = currentChapterProgress,
|
||||||
|
limits = limits,
|
||||||
)
|
)
|
||||||
val pageBasedEta = calculatePageBasedEtaMillis(
|
val pageBasedEta = calculatePageBasedEtaMillis(
|
||||||
chapterDurations = chapterDurations,
|
chapterDurations = chapterDurations,
|
||||||
|
|
@ -1037,6 +1044,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||||
totalPages = state.value.totalPages,
|
totalPages = state.value.totalPages,
|
||||||
currentPage = state.value.currentPage,
|
currentPage = state.value.currentPage,
|
||||||
remainingChapters = remainingChapters,
|
remainingChapters = remainingChapters,
|
||||||
|
limits = limits,
|
||||||
)
|
)
|
||||||
val chapterDurationForWholeChapters = averageChapterDuration ?: projectedCurrentChapterDuration
|
val chapterDurationForWholeChapters = averageChapterDuration ?: projectedCurrentChapterDuration
|
||||||
val chapterDurationForCurrentChapter = projectedCurrentChapterDuration ?: chapterDurationForWholeChapters
|
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()) {
|
val trackedDurations = if (readerPreferences.persistentSeriesEta().get()) {
|
||||||
chapterReadDurationsPersistent.toMutableMap()
|
chapterReadDurationsPersistent.toMutableMap()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1098,7 +1106,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||||
|
|
||||||
val inProgressDuration = chapterReadStartTime
|
val inProgressDuration = chapterReadStartTime
|
||||||
?.let { SystemClock.elapsedRealtime() - it }
|
?.let { SystemClock.elapsedRealtime() - it }
|
||||||
?.sanitizeTrackedDuration()
|
?.sanitizeTrackedDuration(limits)
|
||||||
?: 0L
|
?: 0L
|
||||||
if (inProgressDuration > 0L) {
|
if (inProgressDuration > 0L) {
|
||||||
trackedDurations.merge(currentChapterId, inProgressDuration, Long::plus)
|
trackedDurations.merge(currentChapterId, inProgressDuration, Long::plus)
|
||||||
|
|
@ -1107,7 +1115,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||||
return trackedDurations
|
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()) {
|
val trackedPages = if (readerPreferences.persistentSeriesEta().get()) {
|
||||||
chapterReadPagesPersistent.toMutableMap()
|
chapterReadPagesPersistent.toMutableMap()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1116,7 +1124,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||||
val inProgressPages = chapterReadStartPageIndex
|
val inProgressPages = chapterReadStartPageIndex
|
||||||
?.let { startPageIndex ->
|
?.let { startPageIndex ->
|
||||||
val currentPageIndex = (state.value.currentPage - 1).coerceAtLeast(startPageIndex)
|
val currentPageIndex = (state.value.currentPage - 1).coerceAtLeast(startPageIndex)
|
||||||
(currentPageIndex - startPageIndex + 1L).sanitizeTrackedPages()
|
(currentPageIndex - startPageIndex + 1L).sanitizeTrackedPages(limits)
|
||||||
}
|
}
|
||||||
?: 0L
|
?: 0L
|
||||||
if (inProgressPages > 0L) {
|
if (inProgressPages > 0L) {
|
||||||
|
|
@ -1135,21 +1143,22 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||||
totalPages: Int,
|
totalPages: Int,
|
||||||
currentPage: Int,
|
currentPage: Int,
|
||||||
remainingChapters: Int,
|
remainingChapters: Int,
|
||||||
|
limits: ReadingEtaLimits,
|
||||||
): Long? {
|
): Long? {
|
||||||
val seriesPageRatesMsPerPage = orderedSeriesChapterIds
|
val seriesPageRatesMsPerPage = orderedSeriesChapterIds
|
||||||
.take(currentChapterIndex + 1)
|
.take(currentChapterIndex + 1)
|
||||||
.mapNotNull { chapterId ->
|
.mapNotNull { chapterId ->
|
||||||
val duration = chapterDurations[chapterId]?.sanitizeTrackedDuration() ?: return@mapNotNull null
|
val duration = chapterDurations[chapterId]?.sanitizeTrackedDuration(limits) ?: return@mapNotNull null
|
||||||
val pages = chapterPages[chapterId]?.sanitizeTrackedPages() ?: return@mapNotNull null
|
val pages = chapterPages[chapterId]?.sanitizeTrackedPages(limits) ?: return@mapNotNull null
|
||||||
if (duration <= 0L || pages <= 0L) return@mapNotNull null
|
if (duration <= 0L || pages <= 0L) return@mapNotNull null
|
||||||
duration.toDouble() / pages.toDouble()
|
duration.toDouble() / pages.toDouble()
|
||||||
}
|
}
|
||||||
.takeLast(ETA_SAMPLE_CHAPTER_LIMIT)
|
.takeLast(limits.etaSampleChapterLimit)
|
||||||
|
|
||||||
val sampledPages = orderedSeriesChapterIds
|
val sampledPages = orderedSeriesChapterIds
|
||||||
.take(currentChapterIndex + 1)
|
.take(currentChapterIndex + 1)
|
||||||
.mapNotNull { chapterPages[it]?.sanitizeTrackedPages() }
|
.mapNotNull { chapterPages[it]?.sanitizeTrackedPages(limits) }
|
||||||
.takeLast(ETA_SAMPLE_CHAPTER_LIMIT)
|
.takeLast(limits.etaSampleChapterLimit)
|
||||||
if (sampledPages.isEmpty()) return null
|
if (sampledPages.isEmpty()) return null
|
||||||
val avgPagesPerChapter = (sampledPages.sum() / sampledPages.size).coerceAtLeast(1L)
|
val avgPagesPerChapter = (sampledPages.sum() / sampledPages.size).coerceAtLeast(1L)
|
||||||
|
|
||||||
|
|
@ -1165,6 +1174,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||||
targetChapterPages = targetChapterPages,
|
targetChapterPages = targetChapterPages,
|
||||||
seriesPageRatesMsPerPage = seriesPageRatesMsPerPage,
|
seriesPageRatesMsPerPage = seriesPageRatesMsPerPage,
|
||||||
crossMangaSamples = crossMangaPageRateSamples,
|
crossMangaSamples = crossMangaPageRateSamples,
|
||||||
|
limits = limits,
|
||||||
) ?: return null
|
) ?: return null
|
||||||
|
|
||||||
val currentRemainingPages = if (isCurrentChapterRead) {
|
val currentRemainingPages = if (isCurrentChapterRead) {
|
||||||
|
|
@ -1185,6 +1195,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||||
currentChapterIndex: Int,
|
currentChapterIndex: Int,
|
||||||
isCurrentChapterRead: Boolean,
|
isCurrentChapterRead: Boolean,
|
||||||
currentChapterProgress: Double?,
|
currentChapterProgress: Double?,
|
||||||
|
limits: ReadingEtaLimits,
|
||||||
): Long? {
|
): Long? {
|
||||||
if (currentChapterIndex < 0) return null
|
if (currentChapterIndex < 0) return null
|
||||||
val sampledDurations = orderedSeriesChapterIds
|
val sampledDurations = orderedSeriesChapterIds
|
||||||
|
|
@ -1196,15 +1207,16 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||||
calculateProjectedChapterDurationMs(
|
calculateProjectedChapterDurationMs(
|
||||||
chapterDuration = chapterDuration,
|
chapterDuration = chapterDuration,
|
||||||
chapterProgress = currentChapterProgress,
|
chapterProgress = currentChapterProgress,
|
||||||
|
limits = limits,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
else -> chapterDuration
|
else -> chapterDuration
|
||||||
} ?: return@mapNotNull null
|
} ?: return@mapNotNull null
|
||||||
normalizedDuration
|
normalizedDuration
|
||||||
}
|
}
|
||||||
.filter { it in MIN_TRACKED_CHAPTER_DURATION_MS..MAX_TRACKED_CHAPTER_DURATION_MS }
|
.filter { it in limits.minTrackedChapterDurationMs..limits.maxTrackedChapterDurationMs }
|
||||||
.map { it.coerceAtMost(ReadingEtaDefaults.MAX_CHAPTER_DURATION_MS_FOR_STATS) }
|
.map { it.coerceAtMost(limits.maxChapterDurationMsForStats) }
|
||||||
.takeLast(ETA_SAMPLE_CHAPTER_LIMIT)
|
.takeLast(limits.etaSampleChapterLimit)
|
||||||
if (sampledDurations.isEmpty()) return null
|
if (sampledDurations.isEmpty()) return null
|
||||||
|
|
||||||
val sortedDurations = sampledDurations.sorted()
|
val sortedDurations = sampledDurations.sorted()
|
||||||
|
|
@ -1229,6 +1241,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||||
private fun calculateProjectedChapterDurationMs(
|
private fun calculateProjectedChapterDurationMs(
|
||||||
chapterDuration: Long,
|
chapterDuration: Long,
|
||||||
chapterProgress: Double?,
|
chapterProgress: Double?,
|
||||||
|
limits: ReadingEtaLimits,
|
||||||
): Long? {
|
): Long? {
|
||||||
if (chapterDuration <= 0L) return null
|
if (chapterDuration <= 0L) return null
|
||||||
val progress = chapterProgress
|
val progress = chapterProgress
|
||||||
|
|
@ -1238,7 +1251,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||||
if (progress == 0.0) return null
|
if (progress == 0.0) return null
|
||||||
return (chapterDuration.toDouble() / progress).toLong()
|
return (chapterDuration.toDouble() / progress).toLong()
|
||||||
.coerceAtLeast(0L)
|
.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
|
* Prefer over scaling cumulative DB read time by absolute chapter progress — history times are
|
||||||
* summed across revisits, which inflates `duration / progress` for rereads.
|
* 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 totalPages = state.value.totalPages
|
||||||
val startPageIndex = chapterReadStartPageIndex ?: return null
|
val startPageIndex = chapterReadStartPageIndex ?: return null
|
||||||
val startTime = chapterReadStartTime ?: return null
|
val startTime = chapterReadStartTime ?: return null
|
||||||
if (totalPages <= 1) return null
|
if (totalPages <= 1) return null
|
||||||
|
|
||||||
val sessionMs = (SystemClock.elapsedRealtime() - startTime).sanitizeTrackedDuration()
|
val sessionMs = (SystemClock.elapsedRealtime() - startTime).sanitizeTrackedDuration(limits)
|
||||||
if (sessionMs < MIN_TRACKED_CHAPTER_DURATION_MS) return null
|
if (sessionMs < limits.minTrackedChapterDurationMs) return null
|
||||||
|
|
||||||
val currentPageIndex = (state.value.currentPage - 1).coerceAtLeast(startPageIndex)
|
val currentPageIndex = (state.value.currentPage - 1).coerceAtLeast(startPageIndex)
|
||||||
val pagesThisSession = (currentPageIndex - startPageIndex + 1).coerceAtLeast(1)
|
val pagesThisSession = (currentPageIndex - startPageIndex + 1).coerceAtLeast(1)
|
||||||
|
|
@ -1271,12 +1284,12 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||||
return (duration.toDouble() * remainingProgress).toLong().coerceAtLeast(0L)
|
return (duration.toDouble() * remainingProgress).toLong().coerceAtLeast(0L)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Long.sanitizeTrackedDuration(): Long {
|
private fun Long.sanitizeTrackedDuration(limits: ReadingEtaLimits): Long {
|
||||||
return if (this in MIN_TRACKED_CHAPTER_DURATION_MS..MAX_TRACKED_CHAPTER_DURATION_MS) this else 0L
|
return if (this in limits.minTrackedChapterDurationMs..limits.maxTrackedChapterDurationMs) this else 0L
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Long.sanitizeTrackedPages(): Long {
|
private fun Long.sanitizeTrackedPages(limits: ReadingEtaLimits): Long {
|
||||||
return if (this in MIN_TRACKED_PAGES..MAX_TRACKED_PAGES) this else 0L
|
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 {
|
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_PROGRESS_FOR_DURATION_PROJECTION = 0.05
|
||||||
const val MIN_TRACKED_PAGES = 1L
|
|
||||||
const val MAX_TRACKED_PAGES = 2_000L
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,19 @@ package tachiyomi.domain.history.interactor
|
||||||
import tachiyomi.domain.history.repository.HistoryRepository
|
import tachiyomi.domain.history.repository.HistoryRepository
|
||||||
import tachiyomi.domain.reading.ReadingEtaDefaults
|
import tachiyomi.domain.reading.ReadingEtaDefaults
|
||||||
import tachiyomi.domain.reading.ReadingEtaHistorySample
|
import tachiyomi.domain.reading.ReadingEtaHistorySample
|
||||||
|
import tachiyomi.domain.reading.service.ReadingEtaPreferences
|
||||||
|
|
||||||
class GetCrossMangaPageRateHistorySamples(
|
class GetCrossMangaPageRateHistorySamples(
|
||||||
private val repository: HistoryRepository,
|
private val repository: HistoryRepository,
|
||||||
|
private val readingEtaPreferences: ReadingEtaPreferences,
|
||||||
) {
|
) {
|
||||||
suspend fun await(excludeMangaId: Long): List<ReadingEtaHistorySample> {
|
suspend fun await(excludeMangaId: Long): List<ReadingEtaHistorySample> {
|
||||||
|
val limits = readingEtaPreferences.currentLimits()
|
||||||
return repository.getCrossMangaPageRateHistorySamples(
|
return repository.getCrossMangaPageRateHistorySamples(
|
||||||
excludeMangaId = excludeMangaId,
|
excludeMangaId = excludeMangaId,
|
||||||
minDurationMs = ReadingEtaDefaults.MIN_CHAPTER_DURATION_MS_FOR_PAGE_RATE,
|
minDurationMs = limits.minChapterDurationMsForPageRate,
|
||||||
maxDurationMs = ReadingEtaDefaults.MAX_CHAPTER_DURATION_MS_FOR_PAGE_RATE,
|
maxDurationMs = limits.maxChapterDurationMsForPageRate,
|
||||||
maxPagesRead = ReadingEtaDefaults.MAX_PAGES_READ_FOR_PAGE_RATE,
|
maxPagesRead = limits.maxPagesReadForPageRate,
|
||||||
limit = ReadingEtaDefaults.CROSS_MANGA_PAGE_RATE_SAMPLE_LIMIT,
|
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.
|
* 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.
|
* 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
|
const val MAX_CHAPTER_DURATION_MS_FOR_STATS: Long = 45 * 60 * 1000L
|
||||||
|
|
||||||
/** Same lower bound as reader tracking: ignore ultra-short noise. */
|
/** Ignore ultra-short noise when attributing time to pages (session + history mining). */
|
||||||
const val MIN_CHAPTER_DURATION_MS_FOR_PAGE_RATE: Long = 2_000L
|
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
|
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
|
const val MAX_PAGES_READ_FOR_PAGE_RATE: Long = 2_000L
|
||||||
|
|
||||||
/** Recent history rows scanned for cross-series priors (newest first). */
|
/** 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,
|
targetChapterPages: Int,
|
||||||
seriesPageRatesMsPerPage: List<Double>,
|
seriesPageRatesMsPerPage: List<Double>,
|
||||||
crossMangaSamples: List<ReadingEtaHistorySample>,
|
crossMangaSamples: List<ReadingEtaHistorySample>,
|
||||||
|
limits: ReadingEtaLimits = ReadingEtaLimits.DEFAULT,
|
||||||
): Long? {
|
): Long? {
|
||||||
val n = seriesPageRatesMsPerPage.size
|
val n = seriesPageRatesMsPerPage.size
|
||||||
val seriesAvg = if (n > 0) seriesPageRatesMsPerPage.average() else null
|
val seriesAvg = if (n > 0) seriesPageRatesMsPerPage.average() else null
|
||||||
val prior = estimatePriorMsPerPageFromCrossManga(targetChapterPages, crossMangaSamples)
|
val prior = estimatePriorMsPerPageFromCrossManga(targetChapterPages, crossMangaSamples, limits)
|
||||||
|
|
||||||
return when {
|
return when {
|
||||||
n >= 3 && seriesAvg != null -> seriesAvg.toLong().coerceAtLeast(1L)
|
n >= 3 && seriesAvg != null -> seriesAvg.toLong().coerceAtLeast(1L)
|
||||||
|
|
@ -35,9 +36,10 @@ object ReadingEtaPageRateEstimator {
|
||||||
private fun estimatePriorMsPerPageFromCrossManga(
|
private fun estimatePriorMsPerPageFromCrossManga(
|
||||||
targetChapterPages: Int,
|
targetChapterPages: Int,
|
||||||
samples: List<ReadingEtaHistorySample>,
|
samples: List<ReadingEtaHistorySample>,
|
||||||
|
limits: ReadingEtaLimits,
|
||||||
): Long? {
|
): Long? {
|
||||||
if (targetChapterPages <= 0) return null
|
if (targetChapterPages <= 0) return null
|
||||||
val ratesWithSize = samples.mapNotNull { toRateWithChapterPages(it) }
|
val ratesWithSize = samples.mapNotNull { toRateWithChapterPages(it, limits) }
|
||||||
if (ratesWithSize.isEmpty()) return null
|
if (ratesWithSize.isEmpty()) return null
|
||||||
|
|
||||||
val inner = ratesWithinPageBand(ratesWithSize, targetChapterPages, ReadingEtaDefaults.SIMILAR_CHAPTER_PAGE_COUNT_RATIO)
|
val inner = ratesWithinPageBand(ratesWithSize, targetChapterPages, ReadingEtaDefaults.SIMILAR_CHAPTER_PAGE_COUNT_RATIO)
|
||||||
|
|
@ -49,16 +51,16 @@ object ReadingEtaPageRateEstimator {
|
||||||
return medianMsPerPage(ratesWithSize.map { it.first })
|
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
|
if (!sample.chapterFullyRead) return null
|
||||||
val chapterPages = (sample.lastPageReadIndex + 1L).toInt().coerceAtLeast(1)
|
val chapterPages = (sample.lastPageReadIndex + 1L).toInt().coerceAtLeast(1)
|
||||||
if (sample.pagesRead < ReadingEtaDefaults.MIN_PAGES_READ_FOR_PAGE_RATE) return null
|
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.pagesRead > limits.maxPagesReadForPageRate) return null
|
||||||
if (sample.readDurationMs < ReadingEtaDefaults.MIN_CHAPTER_DURATION_MS_FOR_PAGE_RATE) return null
|
if (sample.readDurationMs < limits.minChapterDurationMsForPageRate) return null
|
||||||
if (sample.readDurationMs > ReadingEtaDefaults.MAX_CHAPTER_DURATION_MS_FOR_PAGE_RATE) return null
|
if (sample.readDurationMs > limits.maxChapterDurationMsForPageRate) return null
|
||||||
if (sample.pagesRead > chapterPages * 2L) 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()
|
val msPerPage = capped.toDouble() / sample.pagesRead.toDouble()
|
||||||
if (!msPerPage.isFinite() || msPerPage <= 0.0) return null
|
if (!msPerPage.isFinite() || msPerPage <= 0.0) return null
|
||||||
return msPerPage to chapterPages
|
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_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">Show Discord Button</string>
|
||||||
<string name="pref_discord_show_discord_button_summary">Displays a button to join the Discord server</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>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -192,4 +192,18 @@
|
||||||
<string name="pref_discord_online">🟢 Online</string>
|
<string name="pref_discord_online">🟢 Online</string>
|
||||||
<string name="discord_status_description">%1$s %2$s</string>
|
<string name="discord_status_description">%1$s %2$s</string>
|
||||||
<string name="pref_discord_rpc">Discord Rich Presence</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>
|
</resources>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue