Reader: track per-page history metrics for ETA and backups

Persist per-session pages read in history, include it in backup/restore, and prefer a page-based ETA model so chapter-length variance (especially webtoons) is reflected more accurately.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
littlecodedragon 2026-05-09 18:35:41 +02:00
parent 384aee7372
commit fe43d3ef2c
10 changed files with 130 additions and 10 deletions

View file

@ -108,7 +108,12 @@ class MangaBackupCreator(
if (historyByMangaId.isNotEmpty()) {
val history = historyByMangaId.map { history ->
val chapter = handler.awaitOne { chaptersQueries.getChapterById(history.chapterId) }
BackupHistory(chapter.url, history.readAt?.time ?: 0L, history.readDuration)
BackupHistory(
url = chapter.url,
lastRead = history.readAt?.time ?: 0L,
readDuration = history.readDuration,
readPages = history.readPages,
)
}
if (history.isNotEmpty()) {
mangaObject.history = history

View file

@ -10,11 +10,13 @@ data class BackupHistory(
@ProtoNumber(1) var url: String,
@ProtoNumber(2) var lastRead: Long,
@ProtoNumber(3) var readDuration: Long = 0,
@ProtoNumber(4) var readPages: Long = 0,
) {
fun getHistoryImpl(): History {
return History.create().copy(
readAt = Date(lastRead),
readDuration = readDuration,
readPages = readPages,
)
}
}

View file

@ -427,6 +427,7 @@ class MangaRestorer(
.takeIf { it > 0L }
?.let { Date(it) },
readDuration = max(item.readDuration, dbHistory.time_read) - dbHistory.time_read,
readPages = max(item.readPages, dbHistory.pages_read) - dbHistory.pages_read,
)
}
@ -437,6 +438,7 @@ class MangaRestorer(
it.chapterId,
it.readAt,
it.readDuration,
it.readPages,
)
}
}

View file

@ -247,9 +247,12 @@ class ReaderViewModel @JvmOverloads constructor(
* The time the chapter was started reading
*/
private var chapterReadStartTime: Long? = null
private var chapterReadStartPageIndex: Int? = null
private val chapterReadDurationsSession = mutableMapOf<Long, Long>()
private val chapterReadDurationsPersistent = mutableMapOf<Long, Long>()
private val chapterReadPagesSession = mutableMapOf<Long, Long>()
private val chapterReadPagesPersistent = mutableMapOf<Long, Long>()
private var orderedSeriesChapterIds = emptyList<Long>()
private var seriesChapterIndexById = emptyMap<Long, Int>()
@ -910,6 +913,7 @@ class ReaderViewModel @JvmOverloads constructor(
fun restartReadTimer() {
chapterReadStartTime = SystemClock.elapsedRealtime()
chapterReadStartPageIndex = getCurrentChapter()?.chapter?.last_page_read ?: 0
updateSeriesProgressState()
}
@ -926,13 +930,24 @@ class ReaderViewModel @JvmOverloads constructor(
?.let { SystemClock.elapsedRealtime() - it }
?.sanitizeTrackedDuration()
?: 0
val sessionReadPages = chapterReadStartPageIndex
?.let { startPageIndex ->
val endPageIndex = readerChapter.chapter.last_page_read
(endPageIndex - startPageIndex + 1L).sanitizeTrackedPages()
}
?: 0L
upsertHistory.await(HistoryUpdate(chapterId, endTime, sessionReadDuration))
upsertHistory.await(HistoryUpdate(chapterId, endTime, sessionReadDuration, sessionReadPages))
if (sessionReadDuration > 0) {
chapterReadDurationsSession.merge(chapterId, sessionReadDuration, Long::plus)
chapterReadDurationsPersistent.merge(chapterId, sessionReadDuration, Long::plus)
}
if (sessionReadPages > 0) {
chapterReadPagesSession.merge(chapterId, sessionReadPages, Long::plus)
chapterReadPagesPersistent.merge(chapterId, sessionReadPages, Long::plus)
}
chapterReadStartTime = null
chapterReadStartPageIndex = null
updateSeriesProgressState()
}
}
@ -948,10 +963,16 @@ class ReaderViewModel @JvmOverloads constructor(
chapterReadDurationsSession.clear()
chapterReadDurationsPersistent.clear()
chapterReadPagesSession.clear()
chapterReadPagesPersistent.clear()
chapterReadDurationsPersistent.putAll(
getHistory.await(manga.id)
.associate { history -> history.chapterId to history.readDuration },
)
chapterReadPagesPersistent.putAll(
getHistory.await(manga.id)
.associate { history -> history.chapterId to history.readPages },
)
updateSeriesProgressState()
}
@ -975,6 +996,7 @@ class ReaderViewModel @JvmOverloads constructor(
totalChapters - currentChapterIndex - if (isCurrentChapterRead) 1 else 0
).coerceAtLeast(0)
val chapterDurations = getEtaChapterDurations(currentChapterId)
val chapterPages = getEtaChapterPages(currentChapterId)
val currentChapterProgress = calculateChapterProgress(
currentPage = state.value.currentPage,
totalPages = state.value.totalPages,
@ -990,10 +1012,20 @@ class ReaderViewModel @JvmOverloads constructor(
isCurrentChapterRead = isCurrentChapterRead,
currentChapterProgress = currentChapterProgress,
)
val pageBasedEta = calculatePageBasedEtaMillis(
chapterDurations = chapterDurations,
chapterPages = chapterPages,
currentChapterId = currentChapterId,
currentChapterIndex = currentChapterIndex,
isCurrentChapterRead = isCurrentChapterRead,
currentChapterProgress = currentChapterProgress,
totalPages = state.value.totalPages,
currentPage = state.value.currentPage,
remainingChapters = remainingChapters,
)
val chapterDurationForWholeChapters = averageChapterDuration ?: projectedCurrentChapterDuration
val chapterDurationForCurrentChapter = projectedCurrentChapterDuration ?: chapterDurationForWholeChapters
val etaMillis = when {
val chapterBasedEta = when {
totalChapters == 1 -> {
if (isCurrentChapterRead) {
0L
@ -1023,7 +1055,8 @@ class ReaderViewModel @JvmOverloads constructor(
}
}
}
}?.coerceAtLeast(0L)
}
val etaMillis = (pageBasedEta ?: chapterBasedEta)?.coerceAtLeast(0L)
mutableState.update {
it.copy(
@ -1059,6 +1092,66 @@ class ReaderViewModel @JvmOverloads constructor(
return trackedDurations
}
private fun getEtaChapterPages(currentChapterId: Long): Map<Long, Long> {
val trackedPages = if (readerPreferences.persistentSeriesEta().get()) {
chapterReadPagesPersistent.toMutableMap()
} else {
chapterReadPagesSession.toMutableMap()
}
val inProgressPages = chapterReadStartPageIndex
?.let { startPageIndex ->
val currentPageIndex = (state.value.currentPage - 1).coerceAtLeast(startPageIndex)
(currentPageIndex - startPageIndex + 1L).sanitizeTrackedPages()
}
?: 0L
if (inProgressPages > 0L) {
trackedPages.merge(currentChapterId, inProgressPages, Long::plus)
}
return trackedPages
}
private fun calculatePageBasedEtaMillis(
chapterDurations: Map<Long, Long>,
chapterPages: Map<Long, Long>,
currentChapterId: Long,
currentChapterIndex: Int,
isCurrentChapterRead: Boolean,
currentChapterProgress: Double?,
totalPages: Int,
currentPage: Int,
remainingChapters: Int,
): Long? {
val pageRates = orderedSeriesChapterIds
.take(currentChapterIndex + 1)
.mapNotNull { chapterId ->
val duration = chapterDurations[chapterId]?.sanitizeTrackedDuration() ?: return@mapNotNull null
val pages = chapterPages[chapterId]?.sanitizeTrackedPages() ?: return@mapNotNull null
if (duration <= 0L || pages <= 0L) return@mapNotNull null
duration.toDouble() / pages.toDouble()
}
.takeLast(ETA_SAMPLE_CHAPTER_LIMIT)
if (pageRates.isEmpty()) return null
val avgMsPerPage = pageRates.average().toLong().coerceAtLeast(1L)
val sampledPages = orderedSeriesChapterIds
.take(currentChapterIndex + 1)
.mapNotNull { chapterPages[it]?.sanitizeTrackedPages() }
.takeLast(ETA_SAMPLE_CHAPTER_LIMIT)
if (sampledPages.isEmpty()) return null
val avgPagesPerChapter = (sampledPages.sum() / sampledPages.size).coerceAtLeast(1L)
val currentRemainingPages = if (isCurrentChapterRead) {
0L
} else if (totalPages > 0 && currentPage > 0) {
(totalPages - currentPage).toLong().coerceAtLeast(0L)
} else {
val progress = (currentChapterProgress ?: 0.0).coerceIn(0.0, 1.0)
((1.0 - progress) * avgPagesPerChapter.toDouble()).toLong().coerceAtLeast(0L)
}
val remainingWholeChapters = (remainingChapters - if (isCurrentChapterRead) 0 else 1).coerceAtLeast(0)
return currentRemainingPages * avgMsPerPage + remainingWholeChapters * avgPagesPerChapter * avgMsPerPage
}
private fun calculateAverageChapterDurationMs(
chapterDurations: Map<Long, Long>,
currentChapterId: Long,
@ -1131,6 +1224,10 @@ class ReaderViewModel @JvmOverloads constructor(
return if (this in MIN_TRACKED_CHAPTER_DURATION_MS..MAX_TRACKED_CHAPTER_DURATION_MS) this else 0L
}
private fun Long.sanitizeTrackedPages(): Long {
return if (this in MIN_TRACKED_PAGES..MAX_TRACKED_PAGES) this else 0L
}
/**
* Called from the activity to load and set the next chapter as active.
*/
@ -1686,6 +1783,8 @@ class ReaderViewModel @JvmOverloads constructor(
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

View file

@ -11,11 +11,13 @@ object HistoryMapper {
chapterId: Long,
readAt: Date?,
readDuration: Long,
readPages: Long,
): History = History(
id = id,
chapterId = chapterId,
readAt = readAt,
readDuration = readDuration,
readPages = readPages,
)
fun mapHistoryWithRelations(

View file

@ -93,6 +93,7 @@ class HistoryRepositoryImpl(
historyUpdate.chapterId,
historyUpdate.readAt,
historyUpdate.sessionReadDuration,
historyUpdate.sessionReadPages,
)
}
} catch (e: Exception) {
@ -109,6 +110,7 @@ class HistoryRepositoryImpl(
historyUpdate.chapterId,
historyUpdate.readAt,
historyUpdate.sessionReadDuration,
historyUpdate.sessionReadPages,
)
}
}

View file

@ -5,6 +5,7 @@ CREATE TABLE history(
chapter_id INTEGER NOT NULL UNIQUE,
last_read INTEGER AS Date,
time_read INTEGER NOT NULL,
pages_read INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(chapter_id) REFERENCES chapters (_id)
ON DELETE CASCADE
);
@ -17,7 +18,8 @@ SELECT
H._id,
H.chapter_id,
H.last_read,
H.time_read
H.time_read,
H.pages_read
FROM history H
JOIN chapters C
ON H.chapter_id = C._id
@ -28,7 +30,8 @@ SELECT
H._id,
H.chapter_id,
H.last_read,
H.time_read
H.time_read,
H.pages_read
FROM history H
JOIN chapters C
ON H.chapter_id = C._id
@ -66,13 +69,14 @@ DELETE FROM history
WHERE last_read = 0;
upsert:
INSERT INTO history(chapter_id, last_read, time_read)
VALUES (:chapterId, :readAt, :time_read)
INSERT INTO history(chapter_id, last_read, time_read, pages_read)
VALUES (:chapterId, :readAt, :time_read, :pages_read)
ON CONFLICT(chapter_id)
DO UPDATE
SET
last_read = :readAt,
time_read = time_read + :time_read
time_read = time_read + :time_read,
pages_read = pages_read + :pages_read
WHERE chapter_id = :chapterId;
getReadDuration:

View file

@ -0,0 +1 @@
ALTER TABLE history ADD COLUMN pages_read INTEGER NOT NULL DEFAULT 0;

View file

@ -7,6 +7,7 @@ data class History(
val chapterId: Long,
val readAt: Date?,
val readDuration: Long,
val readPages: Long = 0,
) {
companion object {
fun create() = History(
@ -14,6 +15,7 @@ data class History(
chapterId = -1L,
readAt = null,
readDuration = -1L,
readPages = 0L,
)
}
}

View file

@ -6,4 +6,5 @@ data class HistoryUpdate(
val chapterId: Long,
val readAt: Date,
val sessionReadDuration: Long,
val sessionReadPages: Long = 0,
)