diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/creators/MangaBackupCreator.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/creators/MangaBackupCreator.kt index 9e8a9338d..7e5f34e01 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/creators/MangaBackupCreator.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/creators/MangaBackupCreator.kt @@ -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 diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupHistory.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupHistory.kt index b5a6c616a..52e55643d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupHistory.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupHistory.kt @@ -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, ) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/MangaRestorer.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/MangaRestorer.kt index 5b4f631c2..8222cfe45 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/MangaRestorer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/MangaRestorer.kt @@ -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, ) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt index 05fbb6f9f..a66f0d96b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt @@ -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() private val chapterReadDurationsPersistent = mutableMapOf() + private val chapterReadPagesSession = mutableMapOf() + private val chapterReadPagesPersistent = mutableMapOf() private var orderedSeriesChapterIds = emptyList() private var seriesChapterIndexById = emptyMap() @@ -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 { + 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, + chapterPages: Map, + 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, 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 diff --git a/data/src/main/java/tachiyomi/data/history/HistoryMapper.kt b/data/src/main/java/tachiyomi/data/history/HistoryMapper.kt index 3dde93430..cd95b31e2 100644 --- a/data/src/main/java/tachiyomi/data/history/HistoryMapper.kt +++ b/data/src/main/java/tachiyomi/data/history/HistoryMapper.kt @@ -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( diff --git a/data/src/main/java/tachiyomi/data/history/HistoryRepositoryImpl.kt b/data/src/main/java/tachiyomi/data/history/HistoryRepositoryImpl.kt index 816e161ca..e055a0100 100644 --- a/data/src/main/java/tachiyomi/data/history/HistoryRepositoryImpl.kt +++ b/data/src/main/java/tachiyomi/data/history/HistoryRepositoryImpl.kt @@ -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, ) } } diff --git a/data/src/main/sqldelight/tachiyomi/data/history.sq b/data/src/main/sqldelight/tachiyomi/data/history.sq index 8677577f4..f50ff2d05 100644 --- a/data/src/main/sqldelight/tachiyomi/data/history.sq +++ b/data/src/main/sqldelight/tachiyomi/data/history.sq @@ -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: diff --git a/data/src/main/sqldelight/tachiyomi/migrations/46.sqm b/data/src/main/sqldelight/tachiyomi/migrations/46.sqm new file mode 100644 index 000000000..e8c922056 --- /dev/null +++ b/data/src/main/sqldelight/tachiyomi/migrations/46.sqm @@ -0,0 +1 @@ +ALTER TABLE history ADD COLUMN pages_read INTEGER NOT NULL DEFAULT 0; diff --git a/domain/src/main/java/tachiyomi/domain/history/model/History.kt b/domain/src/main/java/tachiyomi/domain/history/model/History.kt index 41b58b637..502961688 100644 --- a/domain/src/main/java/tachiyomi/domain/history/model/History.kt +++ b/domain/src/main/java/tachiyomi/domain/history/model/History.kt @@ -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, ) } } diff --git a/domain/src/main/java/tachiyomi/domain/history/model/HistoryUpdate.kt b/domain/src/main/java/tachiyomi/domain/history/model/HistoryUpdate.kt index 62738c33b..2d35c50e8 100644 --- a/domain/src/main/java/tachiyomi/domain/history/model/HistoryUpdate.kt +++ b/domain/src/main/java/tachiyomi/domain/history/model/HistoryUpdate.kt @@ -6,4 +6,5 @@ data class HistoryUpdate( val chapterId: Long, val readAt: Date, val sessionReadDuration: Long, + val sessionReadPages: Long = 0, )