feat(reader): restore series ETA tracking and estimated time left
Some checks failed
Forgejo Release Builder / release (push) Failing after 5m28s

This commit is contained in:
littlecodedragon 2026-05-06 22:04:16 +02:00
parent af860b5f9d
commit 48436e3c46
16 changed files with 528 additions and 18 deletions

View file

@ -263,6 +263,7 @@ private fun ColumnScope.SortPage(
MR.strings.action_sort_chapter_fetch_date to LibrarySort.Type.ChapterFetchDate,
MR.strings.action_sort_date_added to LibrarySort.Type.DateAdded,
trackerMeanPair,
SYMR.strings.action_sort_estimated_time_left to LibrarySort.Type.EstimatedTimeLeft,
// SY -->
tagSortPair,
// SY <--
@ -365,6 +366,10 @@ private fun ColumnScope.DisplayPage(
label = stringResource(MR.strings.action_display_unread_badge),
pref = screenModel.libraryPreferences.unreadBadge(),
)
CheckboxItem(
label = stringResource(SYMR.strings.action_display_estimated_time_left_badge),
pref = screenModel.libraryPreferences.estimatedTimeLeftBadge(),
)
CheckboxItem(
label = stringResource(MR.strings.action_display_local_badge),
pref = screenModel.libraryPreferences.localBadge(),

View file

@ -8,6 +8,7 @@ import androidx.compose.material.icons.outlined.Folder
import androidx.compose.material.icons.outlined.LocalLibrary
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
@ -18,10 +19,13 @@ import eu.kanade.domain.extension.interactor.GetExtensionLanguages.Companion.get
import eu.kanade.domain.source.model.icon
import eu.kanade.presentation.theme.TachiyomiPreviewTheme
import eu.kanade.tachiyomi.R
import eu.kanade.presentation.util.toDurationString
import tachiyomi.domain.source.model.Source
import tachiyomi.presentation.core.components.Badge
import tachiyomi.presentation.core.components.BadgeGroup
import tachiyomi.source.local.isLocal
import kotlin.time.DurationUnit
import kotlin.time.toDuration
@Composable
internal fun DownloadsBadge(count: Long) {
@ -41,6 +45,22 @@ internal fun UnreadBadge(count: Long) {
}
}
@Composable
internal fun EstimatedTimeLeftBadge(etaMillis: Long?) {
val normalizedMillis = etaMillis?.takeIf { it > 0L } ?: return
val context = LocalContext.current
val text = normalizedMillis
.toDuration(DurationUnit.MILLISECONDS)
.toDurationString(context, fallback = "")
.ifBlank { return }
Badge(
text = text,
color = MaterialTheme.colorScheme.secondary,
textColor = MaterialTheme.colorScheme.onSecondary,
)
}
@Composable
internal fun LanguageBadge(
isLocal: Boolean,
@ -121,6 +141,7 @@ private fun BadgePreview() {
BadgeGroup {
DownloadsBadge(count = 10)
UnreadBadge(count = 10)
EstimatedTimeLeftBadge(etaMillis = 4_500_000)
LanguageBadge(isLocal = true, sourceLanguage = "en")
LanguageBadge(isLocal = false, sourceLanguage = "en", useLangIcon = false)
LanguageBadge(isLocal = false, sourceLanguage = "vi")

View file

@ -49,6 +49,7 @@ internal fun LibraryComfortableGrid(
coverBadgeStart = {
DownloadsBadge(count = libraryItem.downloadCount)
UnreadBadge(count = libraryItem.unreadCount)
EstimatedTimeLeftBadge(etaMillis = libraryItem.estimatedTimeLeftMillis)
},
coverBadgeEnd = {
LanguageBadge(

View file

@ -47,6 +47,7 @@ internal fun LibraryCompactGrid(
coverBadgeStart = {
DownloadsBadge(count = libraryItem.downloadCount)
UnreadBadge(count = libraryItem.unreadCount)
EstimatedTimeLeftBadge(etaMillis = libraryItem.estimatedTimeLeftMillis)
},
coverBadgeEnd = {
LanguageBadge(

View file

@ -56,6 +56,7 @@ internal fun LibraryList(
badge = {
DownloadsBadge(count = libraryItem.downloadCount)
UnreadBadge(count = libraryItem.unreadCount)
EstimatedTimeLeftBadge(etaMillis = libraryItem.estimatedTimeLeftMillis)
LanguageBadge(
isLocal = libraryItem.isLocal,
sourceLanguage = libraryItem.sourceLanguage,

View file

@ -121,6 +121,7 @@ object SettingsReaderScreen : SearchableSettings {
private fun getDisplayGroup(readerPreferences: ReaderPreferences): Preference.PreferenceGroup {
val fullscreenPref = readerPreferences.fullscreen()
val fullscreen by fullscreenPref.collectAsState()
val showSeriesEta by readerPreferences.showSeriesEta().collectAsState()
return Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_category_display),
preferenceItems = persistentListOf(
@ -158,6 +159,20 @@ object SettingsReaderScreen : SearchableSettings {
preference = readerPreferences.showPageNumber(),
title = stringResource(MR.strings.pref_show_page_number),
),
Preference.PreferenceItem.SwitchPreference(
preference = readerPreferences.showSeriesProgress(),
title = stringResource(SYMR.strings.pref_show_series_progress),
),
Preference.PreferenceItem.SwitchPreference(
preference = readerPreferences.showSeriesEta(),
title = stringResource(SYMR.strings.pref_show_series_eta),
),
Preference.PreferenceItem.SwitchPreference(
preference = readerPreferences.persistentSeriesEta(),
title = stringResource(SYMR.strings.pref_persist_series_eta),
subtitle = stringResource(SYMR.strings.pref_persist_series_eta_summary),
enabled = showSeriesEta,
),
),
)
}

View file

@ -13,6 +13,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
@ -21,19 +22,27 @@ import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import eu.kanade.presentation.reader.components.ChapterNavigator
import eu.kanade.presentation.util.toDurationString
import eu.kanade.tachiyomi.ui.reader.ReaderViewModel
import eu.kanade.tachiyomi.ui.reader.setting.ReaderOrientation
import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode
import eu.kanade.tachiyomi.ui.reader.viewer.Viewer
import eu.kanade.tachiyomi.ui.reader.viewer.pager.R2LPagerViewer
import kotlinx.collections.immutable.ImmutableSet
import tachiyomi.i18n.sy.SYMR
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource
import kotlin.time.DurationUnit
import kotlin.time.toDuration
private val readerBarsSlideAnimationSpec = tween<IntOffset>(200)
private val readerBarsFadeAnimationSpec = tween<Float>(150)
@ -91,6 +100,9 @@ fun ReaderAppBars(
onClickBoostPageHelp: () -> Unit,
navBarType: NavBarType,
currentPageText: String,
seriesReadingProgress: ReaderViewModel.SeriesReadingProgress?,
showSeriesProgress: Boolean,
showSeriesEta: Boolean,
enabledButtons: ImmutableSet<String>,
currentReadingMode: ReadingMode,
dualPageSplitEnabled: Boolean,
@ -250,6 +262,11 @@ fun ReaderAppBars(
// SY <--
)
}
ReaderSeriesProgressInfo(
progress = seriesReadingProgress,
showSeriesProgress = showSeriesProgress,
showSeriesEta = showSeriesEta,
)
ReaderBottomBar(
modifier = Modifier
.fillMaxWidth()
@ -280,3 +297,61 @@ fun ReaderAppBars(
}
}
}
@Composable
private fun ReaderSeriesProgressInfo(
progress: ReaderViewModel.SeriesReadingProgress?,
showSeriesProgress: Boolean,
showSeriesEta: Boolean,
) {
if (progress == null || (!showSeriesProgress && !showSeriesEta)) return
val context = LocalContext.current
val infoParts = buildList {
if (showSeriesProgress) {
add(
stringResource(
SYMR.strings.reader_series_progress_value,
progress.currentChapter,
progress.totalChapters,
),
)
}
if (showSeriesEta) {
val etaText = progress.etaMillis
?.toDuration(DurationUnit.MILLISECONDS)
?.toDurationString(context, fallback = "")
?.takeIf { it.isNotBlank() }
add(
etaText
?.let { stringResource(SYMR.strings.reader_series_eta_value, it) }
?: stringResource(SYMR.strings.reader_series_eta_unknown),
)
}
}
if (infoParts.isEmpty()) return
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = MaterialTheme.padding.medium),
horizontalArrangement = Arrangement.Center,
) {
Text(
text = infoParts.joinToString(separator = ""),
modifier = Modifier
.background(
color = MaterialTheme.colorScheme
.surfaceColorAtElevation(6.dp)
.copy(alpha = 0.92f),
shape = MaterialTheme.shapes.small,
)
.padding(
horizontal = MaterialTheme.padding.small,
vertical = MaterialTheme.padding.extraSmall,
),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface,
)
}
}

View file

@ -61,6 +61,24 @@ internal fun GeneralPage(screenModel: ReaderSettingsScreenModel) {
pref = screenModel.preferences.showPageNumber(),
)
CheckboxItem(
label = stringResource(SYMR.strings.pref_show_series_progress),
pref = screenModel.preferences.showSeriesProgress(),
)
val showSeriesEta by screenModel.preferences.showSeriesEta().collectAsState()
CheckboxItem(
label = stringResource(SYMR.strings.pref_show_series_eta),
pref = screenModel.preferences.showSeriesEta(),
)
if (showSeriesEta) {
CheckboxItem(
label = stringResource(SYMR.strings.pref_persist_series_eta),
pref = screenModel.preferences.persistentSeriesEta(),
)
}
// SY -->
val forceHorizontalSeekbar by screenModel.preferences.forceHorizontalSeekbar().collectAsState()
CheckboxItem(

View file

@ -12,6 +12,7 @@ data class LibraryItem(
val libraryManga: LibraryManga,
val downloadCount: Long = -1,
val unreadCount: Long = -1,
val estimatedTimeLeftMillis: Long? = null,
val isLocal: Boolean = false,
val sourceLanguage: String = "",
// KMK -->

View file

@ -98,6 +98,8 @@ import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
import tachiyomi.domain.chapter.interactor.GetMergedChaptersByMangaId
import tachiyomi.domain.chapter.model.Chapter
import tachiyomi.domain.history.interactor.GetNextChapters
import tachiyomi.domain.history.interactor.GetReadDurationEntriesByMangaIds
import tachiyomi.domain.history.interactor.GetTotalReadDuration
import tachiyomi.domain.library.model.LibraryDisplayMode
import tachiyomi.domain.library.model.LibraryGroup
import tachiyomi.domain.library.model.LibraryManga
@ -148,6 +150,8 @@ class LibraryScreenModel(
private val exhPreferences: ExhPreferences = Injekt.get(),
private val sourcePreferences: SourcePreferences = Injekt.get(),
private val getMergedMangaById: GetMergedMangaById = Injekt.get(),
private val getReadDurationEntriesByMangaIds: GetReadDurationEntriesByMangaIds = Injekt.get(),
private val getTotalReadDuration: GetTotalReadDuration = Injekt.get(),
private val getTracks: GetTracks = Injekt.get(),
private val getIdsOfFavoriteMangaWithMetadata: GetIdsOfFavoriteMangaWithMetadata = Injekt.get(),
private val getSearchTags: GetSearchTags = Injekt.get(),
@ -190,8 +194,19 @@ class LibraryScreenModel(
),
// KMK <--
getLibraryItemPreferencesFlow(),
) { (searchQuery, categories, favorites), (tracksMap, trackingFilters), (includedCategories, excludedCategories), itemPreferences ->
val filteredFavorites = favorites
getTotalReadDuration.subscribe().distinctUntilChanged(),
) { (searchQuery, categories, favorites), (tracksMap, trackingFilters), (includedCategories, excludedCategories), itemPreferences, _ ->
val estimatedTimeLeftByMangaId = computeEstimatedTimeLeftByMangaId(favorites)
val favoritesWithEstimatedTimeLeft = favorites.map { item ->
item.copy(
estimatedTimeLeftMillis = if (itemPreferences.estimatedTimeLeftBadge) {
estimatedTimeLeftByMangaId[item.id]
} else {
null
},
)
}
val filteredFavorites = favoritesWithEstimatedTimeLeft
.applyFilters(
tracksMap,
trackingFilters,
@ -219,6 +234,7 @@ class LibraryScreenModel(
isInitialized = true,
categories = categories,
favorites = filteredFavorites,
estimatedTimeLeftByMangaId = estimatedTimeLeftByMangaId,
tracksMap = tracksMap,
loggedInTrackerIds = trackingFilters.keys,
)
@ -275,6 +291,7 @@ class LibraryScreenModel(
)
.applySort(
data.favoritesById,
data.estimatedTimeLeftByMangaId,
data.tracksMap,
data.loggedInTrackerIds,
// SY -->
@ -421,6 +438,82 @@ class LibraryScreenModel(
// KMK <--
}
private suspend fun computeEstimatedTimeLeftByMangaId(
favorites: List<LibraryItem>,
): Map<Long, Long?> {
if (favorites.isEmpty()) return emptyMap()
val chapterDurationsByMangaId = getReadDurationEntriesByMangaIds.await(
favorites.map { it.id },
)
.groupBy { it.mangaId }
.mapValues { (_, entries) -> entries.map { it.readDuration } }
val globalAverageChapterDurationMs = calculateAverageChapterDurationMs(
chapterDurationsByMangaId.values.flatten(),
)
return favorites.associate { item ->
item.id to calculateEstimatedTimeLeftMs(
item = item,
chapterDurations = chapterDurationsByMangaId[item.id].orEmpty(),
globalAverageChapterDurationMs = globalAverageChapterDurationMs,
)
}
}
private fun calculateEstimatedTimeLeftMs(
item: LibraryItem,
chapterDurations: List<Long>,
globalAverageChapterDurationMs: Long?,
): Long? {
val unreadCount = item.libraryManga.unreadCount.coerceAtLeast(0L)
if (unreadCount == 0L) return null
val averageChapterDurationMs = calculateAverageChapterDurationMs(chapterDurations)
?: globalAverageChapterDurationMs
?: return null
val remainingChapterUnits = if (item.libraryManga.totalChapters == 1L) {
1L
} else {
unreadCount
}
return (averageChapterDurationMs * remainingChapterUnits).coerceAtLeast(0L)
}
private fun calculateAverageChapterDurationMs(
chapterDurations: List<Long>,
): Long? {
val sampledDurations = chapterDurations
.filter { it in MIN_TRACKED_CHAPTER_DURATION_MS..MAX_TRACKED_CHAPTER_DURATION_MS }
.takeLast(ETA_SAMPLE_CHAPTER_LIMIT)
if (sampledDurations.isEmpty()) return null
val sortedDurations = sampledDurations.sorted()
val trimCount = (sortedDurations.size / 10)
.coerceAtMost((sortedDurations.size - 1) / 2)
val trimmedDurations = if (trimCount > 0) {
sortedDurations.subList(trimCount, sortedDurations.size - trimCount)
} else {
sortedDurations
}
return trimmedDurations.sum() / trimmedDurations.size
}
private fun compareEstimatedTimeLeft(
first: Long?,
second: Long?,
ascending: Boolean,
): Int {
return when {
first == null && second == null -> 0
first == null -> 1
second == null -> -1
ascending -> first.compareTo(second)
else -> second.compareTo(first)
}
}
private suspend fun List<LibraryItem>.applyFilters(
trackMap: Map<Long, List<Track>>,
trackingFilter: Map<Long, TriState>,
@ -621,6 +714,7 @@ class LibraryScreenModel(
private fun Map<Category, List</* LibraryItem */ Long>>.applySort(
favoritesById: Map<Long, LibraryItem>,
estimatedTimeLeftByMangaId: Map<Long, Long?>,
trackMap: Map<Long, List<Track>>,
loggedInTrackerIds: Set<Long>,
// SY -->
@ -700,6 +794,13 @@ class LibraryScreenModel(
val item2Score = trackerScores[manga2.id] ?: defaultTrackerScoreSortValue
item1Score.compareTo(item2Score)
}
LibrarySort.Type.EstimatedTimeLeft -> {
compareEstimatedTimeLeft(
estimatedTimeLeftByMangaId[manga1.id],
estimatedTimeLeftByMangaId[manga2.id],
sort.isAscending,
)
}
LibrarySort.Type.Random -> {
error("Why Are We Still Here? Just To Suffer?")
}
@ -730,7 +831,13 @@ class LibraryScreenModel(
// SY -->
val comparator = sort.comparator()
// SY <--
.let { if (/* SY --> */ sort.isAscending /* SY <-- */) it else it.reversed() }
.let {
if (sort.type == LibrarySort.Type.EstimatedTimeLeft || sort.isAscending) {
it
} else {
it.reversed()
}
}
.thenComparator(sortAlphabetically)
manga.sortedWith(comparator).map { it.id }
@ -743,6 +850,7 @@ class LibraryScreenModel(
libraryPreferences.unreadBadge().changes(),
libraryPreferences.localBadge().changes(),
libraryPreferences.languageBadge().changes(),
libraryPreferences.estimatedTimeLeftBadge().changes(),
libraryPreferences.autoUpdateMangaRestrictions().changes(),
preferences.downloadedOnly().changes(),
@ -766,21 +874,22 @@ class LibraryScreenModel(
unreadBadge = it[1] as Boolean,
localBadge = it[2] as Boolean,
languageBadge = it[3] as Boolean,
skipOutsideReleasePeriod = LibraryPreferences.MANGA_OUTSIDE_RELEASE_PERIOD in (it[4] as Set<*>),
globalFilterDownloaded = it[5] as Boolean,
filterDownloaded = it[6] as TriState,
filterUnread = it[7] as TriState,
filterStarted = it[8] as TriState,
filterBookmarked = it[9] as TriState,
filterCompleted = it[10] as TriState,
filterIntervalCustom = it[11] as TriState,
estimatedTimeLeftBadge = it[4] as Boolean,
skipOutsideReleasePeriod = LibraryPreferences.MANGA_OUTSIDE_RELEASE_PERIOD in (it[5] as Set<*>),
globalFilterDownloaded = it[6] as Boolean,
filterDownloaded = it[7] as TriState,
filterUnread = it[8] as TriState,
filterStarted = it[9] as TriState,
filterBookmarked = it[10] as TriState,
filterCompleted = it[11] as TriState,
filterIntervalCustom = it[12] as TriState,
// SY -->
filterLewd = it[12] as TriState,
filterLewd = it[13] as TriState,
// SY <--
// KMK -->
sourceBadge = it[13] as Boolean,
useLangIcon = it[14] as Boolean,
filterCategories = it[15] as Boolean,
sourceBadge = it[14] as Boolean,
useLangIcon = it[15] as Boolean,
filterCategories = it[16] as Boolean,
)
}
}
@ -1625,6 +1734,7 @@ class LibraryScreenModel(
val unreadBadge: Boolean,
val localBadge: Boolean,
val languageBadge: Boolean,
val estimatedTimeLeftBadge: Boolean,
// KMK -->
val useLangIcon: Boolean,
val sourceBadge: Boolean,
@ -1651,6 +1761,7 @@ class LibraryScreenModel(
val isInitialized: Boolean = false,
val categories: List<Category> = emptyList(),
val favorites: List<LibraryItem> = emptyList(),
val estimatedTimeLeftByMangaId: Map<Long, Long?> = emptyMap(),
val tracksMap: Map</* Manga */ Long, List<Track>> = emptyMap(),
val loggedInTrackerIds: Set<Long> = emptySet(),
) {
@ -1768,6 +1879,10 @@ 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>()

View file

@ -665,6 +665,8 @@ class ReaderActivity : BaseActivity() {
val readerBottomButtons by readerPreferences.readerBottomButtons().changes().map { it.toImmutableSet() }
.collectAsState(persistentSetOf())
val dualPageSplitPaged by readerPreferences.dualPageSplitPaged().collectAsState()
val showSeriesProgress by readerPreferences.showSeriesProgress().collectAsState()
val showSeriesEta by readerPreferences.showSeriesEta().collectAsState()
val forceHorizontalSeekbar by readerPreferences.forceHorizontalSeekbar().collectAsState()
val landscapeVerticalSeekbar by readerPreferences.landscapeVerticalSeekbar().collectAsState()
@ -737,6 +739,9 @@ class ReaderActivity : BaseActivity() {
onClickBoostPage = ::exhBoostPage,
onClickBoostPageHelp = viewModel::openBoostPageHelp,
currentPageText = state.currentPageText,
seriesReadingProgress = state.seriesReadingProgress,
showSeriesProgress = showSeriesProgress,
showSeriesEta = showSeriesEta,
navBarType = navBarType,
enabledButtons = readerBottomButtons,
currentReadingMode = ReadingMode.fromPreference(

View file

@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.ui.reader
import android.app.Application
import android.net.Uri
import android.os.SystemClock
import androidx.annotation.ColorInt
import androidx.annotation.IntRange
import androidx.compose.runtime.Immutable
@ -91,6 +92,7 @@ import tachiyomi.domain.chapter.model.Chapter
import tachiyomi.domain.chapter.model.ChapterUpdate
import tachiyomi.domain.chapter.service.getChapterSort
import tachiyomi.domain.download.service.DownloadPreferences
import tachiyomi.domain.history.interactor.GetHistory
import tachiyomi.domain.history.interactor.GetNextChapters
import tachiyomi.domain.history.interactor.UpsertHistory
import tachiyomi.domain.history.model.HistoryUpdate
@ -104,7 +106,6 @@ import tachiyomi.domain.source.service.SourceManager
import tachiyomi.source.local.isLocal
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.time.Instant
import java.util.Date
/**
@ -125,6 +126,7 @@ class ReaderViewModel @JvmOverloads constructor(
private val getManga: GetManga = Injekt.get(),
private val getChaptersByMangaId: GetChaptersByMangaId = Injekt.get(),
private val getNextChapters: GetNextChapters = Injekt.get(),
private val getHistory: GetHistory = Injekt.get(),
private val upsertHistory: UpsertHistory = Injekt.get(),
private val updateChapter: UpdateChapter = Injekt.get(),
private val setMangaViewerFlags: SetMangaViewerFlags = Injekt.get(),
@ -246,6 +248,12 @@ class ReaderViewModel @JvmOverloads constructor(
*/
private var chapterReadStartTime: Long? = null
private val chapterReadDurationsSession = mutableMapOf<Long, Long>()
private val chapterReadDurationsPersistent = mutableMapOf<Long, Long>()
private var orderedSeriesChapterIds = emptyList<Long>()
private var seriesChapterIndexById = emptyMap<Long, Int>()
private var chapterToDownload: Download? = null
private val unfilteredChapterList by lazy {
@ -488,6 +496,7 @@ class ReaderViewModel @JvmOverloads constructor(
page,
// SY <--
)
initializeSeriesProgressState()
Result.success(true)
} else {
// Unlikely but okay
@ -557,6 +566,7 @@ class ReaderViewModel @JvmOverloads constructor(
)
}
}
updateSeriesProgressState()
return newChapters
}
@ -818,6 +828,7 @@ class ReaderViewModel @JvmOverloads constructor(
mutableState.update {
it.copy(currentPage = pageIndex + 1)
}
updateSeriesProgressState()
readerChapter.requestedPage = pageIndex
chapterPageIndex = pageIndex
@ -846,6 +857,7 @@ class ReaderViewModel @JvmOverloads constructor(
lastPageRead = readerChapter.chapter.last_page_read.toLong(),
),
)
updateSeriesProgressState()
// SY -->
// Check if syncing is enabled for chapter open:
@ -897,7 +909,8 @@ class ReaderViewModel @JvmOverloads constructor(
}
fun restartReadTimer() {
chapterReadStartTime = Instant.now().toEpochMilli()
chapterReadStartTime = SystemClock.elapsedRealtime()
updateSeriesProgressState()
}
/**
@ -909,13 +922,215 @@ class ReaderViewModel @JvmOverloads constructor(
val chapterId = readerChapter.chapter.id!!
val endTime = Date()
val sessionReadDuration = chapterReadStartTime?.let { endTime.time - it } ?: 0
val sessionReadDuration = chapterReadStartTime
?.let { SystemClock.elapsedRealtime() - it }
?.sanitizeTrackedDuration()
?: 0
upsertHistory.await(HistoryUpdate(chapterId, endTime, sessionReadDuration))
if (sessionReadDuration > 0) {
chapterReadDurationsSession.merge(chapterId, sessionReadDuration, Long::plus)
chapterReadDurationsPersistent.merge(chapterId, sessionReadDuration, Long::plus)
}
chapterReadStartTime = null
updateSeriesProgressState()
}
}
private suspend fun initializeSeriesProgressState() {
val manga = manga ?: return
orderedSeriesChapterIds = unfilteredChapterList
.sortedWith(getChapterSort(manga, sortDescending = false))
.mapNotNull { it.id }
seriesChapterIndexById = orderedSeriesChapterIds
.withIndex()
.associate { (index, chapterId) -> chapterId to index }
chapterReadDurationsSession.clear()
chapterReadDurationsPersistent.clear()
chapterReadDurationsPersistent.putAll(
getHistory.await(manga.id)
.associate { history -> history.chapterId to history.readDuration },
)
updateSeriesProgressState()
}
private fun updateSeriesProgressState() {
try {
val currentChapterId = state.value.currentChapter?.chapter?.id
if (currentChapterId == null || orderedSeriesChapterIds.isEmpty()) {
mutableState.update { it.copy(seriesReadingProgress = null) }
return
}
val currentChapterIndex = seriesChapterIndexById[currentChapterId]
if (currentChapterIndex == null) {
mutableState.update { it.copy(seriesReadingProgress = null) }
return
}
val totalChapters = orderedSeriesChapterIds.size
val isCurrentChapterRead = state.value.currentChapter?.chapter?.read == true
val remainingChapters = (
totalChapters - currentChapterIndex - if (isCurrentChapterRead) 1 else 0
).coerceAtLeast(0)
val chapterDurations = getEtaChapterDurations(currentChapterId)
val currentChapterProgress = calculateChapterProgress(
currentPage = state.value.currentPage,
totalPages = state.value.totalPages,
)
val projectedCurrentChapterDuration = calculateProjectedChapterDurationMs(
chapterDuration = chapterDurations[currentChapterId] ?: 0L,
chapterProgress = currentChapterProgress,
)
val averageChapterDuration = calculateAverageChapterDurationMs(
chapterDurations = chapterDurations,
currentChapterId = currentChapterId,
currentChapterIndex = currentChapterIndex,
isCurrentChapterRead = isCurrentChapterRead,
currentChapterProgress = currentChapterProgress,
)
val chapterDurationForWholeChapters = averageChapterDuration ?: projectedCurrentChapterDuration
val chapterDurationForCurrentChapter = projectedCurrentChapterDuration ?: chapterDurationForWholeChapters
val etaMillis = when {
totalChapters == 1 -> {
if (isCurrentChapterRead) {
0L
} else {
calculateRemainingChapterTimeMs(
chapterDuration = chapterDurationForCurrentChapter,
chapterProgress = currentChapterProgress,
) ?: chapterDurationForCurrentChapter
}
}
else -> {
when {
remainingChapters == 0 -> 0L
else -> chapterDurationForWholeChapters?.let { chapterDurationMs ->
val currentChapterRemainingMs = if (isCurrentChapterRead) {
0L
} else {
calculateRemainingChapterTimeMs(
chapterDuration = chapterDurationForCurrentChapter,
chapterProgress = currentChapterProgress,
) ?: chapterDurationForCurrentChapter ?: 0L
}
val remainingWholeChapters = (
remainingChapters - if (isCurrentChapterRead) 0 else 1
).coerceAtLeast(0)
currentChapterRemainingMs + (chapterDurationMs * remainingWholeChapters)
}
}
}
}?.coerceAtLeast(0L)
mutableState.update {
it.copy(
seriesReadingProgress = SeriesReadingProgress(
currentChapter = currentChapterIndex + 1,
totalChapters = totalChapters,
remainingChapters = remainingChapters,
etaMillis = etaMillis,
),
)
}
} catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Failed to update series reading progress" }
mutableState.update { it.copy(seriesReadingProgress = null) }
}
}
private fun getEtaChapterDurations(currentChapterId: Long): Map<Long, Long> {
val trackedDurations = if (readerPreferences.persistentSeriesEta().get()) {
chapterReadDurationsPersistent.toMutableMap()
} else {
chapterReadDurationsSession.toMutableMap()
}
val inProgressDuration = chapterReadStartTime
?.let { SystemClock.elapsedRealtime() - it }
?.sanitizeTrackedDuration()
?: 0L
if (inProgressDuration > 0L) {
trackedDurations.merge(currentChapterId, inProgressDuration, Long::plus)
}
return trackedDurations
}
private fun calculateAverageChapterDurationMs(
chapterDurations: Map<Long, Long>,
currentChapterId: Long,
currentChapterIndex: Int,
isCurrentChapterRead: Boolean,
currentChapterProgress: Double?,
): Long? {
if (currentChapterIndex < 0) return null
val sampledDurations = orderedSeriesChapterIds
.take(currentChapterIndex + 1)
.mapNotNull { chapterId ->
val chapterDuration = chapterDurations[chapterId] ?: return@mapNotNull null
val normalizedDuration = when {
chapterId == currentChapterId && !isCurrentChapterRead -> {
calculateProjectedChapterDurationMs(
chapterDuration = chapterDuration,
chapterProgress = currentChapterProgress,
)
}
else -> chapterDuration
} ?: return@mapNotNull null
normalizedDuration
}
.filter { it in MIN_TRACKED_CHAPTER_DURATION_MS..MAX_TRACKED_CHAPTER_DURATION_MS }
.takeLast(ETA_SAMPLE_CHAPTER_LIMIT)
if (sampledDurations.isEmpty()) return null
val sortedDurations = sampledDurations.sorted()
val trimCount = (sortedDurations.size / 10)
.coerceAtMost((sortedDurations.size - 1) / 2)
val trimmedDurations = if (trimCount > 0) {
sortedDurations.subList(trimCount, sortedDurations.size - trimCount)
} else {
sortedDurations
}
return trimmedDurations.sum() / trimmedDurations.size
}
private fun calculateChapterProgress(
currentPage: Int,
totalPages: Int,
): Double? {
if (currentPage <= 0 || totalPages <= 1) return null
return (currentPage.toDouble() / totalPages.toDouble()).coerceIn(0.0, 1.0)
}
private fun calculateProjectedChapterDurationMs(
chapterDuration: Long,
chapterProgress: Double?,
): Long? {
if (chapterDuration <= 0L) return null
val progress = chapterProgress
?.takeIf { it >= MIN_PROGRESS_FOR_DURATION_PROJECTION }
?.coerceIn(0.0, 1.0)
?: return null
if (progress == 0.0) return null
return (chapterDuration.toDouble() / progress).toLong().coerceAtLeast(0L)
}
private fun calculateRemainingChapterTimeMs(
chapterDuration: Long?,
chapterProgress: Double?,
): Long? {
val duration = chapterDuration?.takeIf { it > 0L } ?: return null
val remainingProgress = 1.0 - (chapterProgress ?: 0.0).coerceIn(0.0, 1.0)
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
}
/**
* Called from the activity to load and set the next chapter as active.
*/
@ -1466,6 +1681,13 @@ 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
}
@Immutable
data class State(
val manga: Manga? = null,
@ -1496,6 +1718,7 @@ class ReaderViewModel @JvmOverloads constructor(
val autoScroll: Boolean = false,
val isAutoScrollEnabled: Boolean = false,
val ehAutoscrollFreq: String = "",
val seriesReadingProgress: SeriesReadingProgress? = null,
// SY <--
) {
val currentChapter: ReaderChapter?
@ -1505,6 +1728,14 @@ class ReaderViewModel @JvmOverloads constructor(
get() = currentChapter?.pages?.size ?: -1
}
@Immutable
data class SeriesReadingProgress(
val currentChapter: Int,
val totalChapters: Int,
val remainingChapters: Int,
val etaMillis: Long?,
)
sealed interface Dialog {
data object Loading : Dialog
data object Settings : Dialog

View file

@ -35,6 +35,12 @@ class ReaderPreferences(
fun showPageNumber() = preferenceStore.getBoolean("pref_show_page_number_key", true)
fun showSeriesProgress() = preferenceStore.getBoolean("pref_show_series_progress_key", true)
fun showSeriesEta() = preferenceStore.getBoolean("pref_show_series_eta_key", true)
fun persistentSeriesEta() = preferenceStore.getBoolean("pref_persist_series_eta_key", true)
fun showReadingMode() = preferenceStore.getBoolean("pref_show_reading_mode", true)
fun fullscreen() = preferenceStore.getBoolean("fullscreen", true)

View file

@ -31,6 +31,7 @@ data class LibrarySort(
data object ChapterFetchDate : Type(0b00011000)
data object DateAdded : Type(0b00011100)
data object TrackerMean : Type(0b00100000)
data object EstimatedTimeLeft : Type(0b00101000)
data object Random : Type(0b00111100)
// SY -->
@ -82,6 +83,7 @@ data class LibrarySort(
Type.ChapterFetchDate,
Type.DateAdded,
Type.TrackerMean,
Type.EstimatedTimeLeft,
/* SY -->*/ Type.TagList, /* SY <--*/
Type.Random,
)
@ -111,6 +113,7 @@ data class LibrarySort(
"CHAPTER_FETCH_DATE" -> Type.ChapterFetchDate
"DATE_ADDED" -> Type.DateAdded
"TRACKER_MEAN" -> Type.TrackerMean
"ESTIMATED_TIME_LEFT" -> Type.EstimatedTimeLeft
// SY -->
"TAG_LIST" -> Type.TagList
// SY <--
@ -136,6 +139,7 @@ data class LibrarySort(
Type.ChapterFetchDate -> "CHAPTER_FETCH_DATE"
Type.DateAdded -> "DATE_ADDED"
Type.TrackerMean -> "TRACKER_MEAN"
Type.EstimatedTimeLeft -> "ESTIMATED_TIME_LEFT"
// SY -->
Type.TagList -> "TAG_LIST"
// SY <--

View file

@ -145,6 +145,8 @@ class LibraryPreferences(
fun unreadBadge() = preferenceStore.getBoolean("display_unread_badge", true)
fun estimatedTimeLeftBadge() = preferenceStore.getBoolean("display_estimated_time_left_badge", false)
fun localBadge() = preferenceStore.getBoolean("display_local_badge", true)
fun languageBadge() = preferenceStore.getBoolean("display_language_badge", true)

View file

@ -193,6 +193,8 @@
<string name="pref_mark_read_dupe_chapters_summary">Mark duplicate chapters as read after reading</string>
<string name="pref_library_mark_duplicate_chapters">Mark new duplicate chapters as read</string>
<string name="pref_library_mark_duplicate_chapters_summary">Automatically mark new chapters as read if it has been read before</string>
<string name="action_sort_estimated_time_left">Estimated time left</string>
<string name="action_display_estimated_time_left_badge">Show estimated time left badge</string>
<string name="update_30min">Every 30 minutes</string>
<string name="update_1hour">Every hour</string>
<string name="update_3hour">Every 3 hours</string>
@ -323,6 +325,10 @@
<string name="pref_left_handed_vertical_seekbar_summary">Switches which side the seekbar is on</string>
<string name="pref_force_horz_seekbar">Force horizontal seekbar</string>
<string name="pref_force_horz_seekbar_summary">Removes vertical seekbar entirely in favour of horizontal</string>
<string name="pref_show_series_progress">Show series chapter progress</string>
<string name="pref_show_series_eta">Show estimated time to series end</string>
<string name="pref_persist_series_eta">Persist ETA data between sessions</string>
<string name="pref_persist_series_eta_summary">When disabled, ETA is only based on the current reading session</string>
<string name="pref_smooth_scroll">Smooth Auto Scroll</string>
<!-- Reader -->
@ -357,6 +363,9 @@
<string name="action_copy_first_page">Copy first page</string>
<string name="action_copy_second_page">Copy second page</string>
<string name="action_copy_combined_page">Copy combined page</string>
<string name="reader_series_progress_value">Chapter %1$d/%2$d</string>
<string name="reader_series_eta_value">ETA %1$s</string>
<string name="reader_series_eta_unknown">ETA --</string>
<!-- Reader Sharing -->
<string name="share_pages_info">%1$s: %2$s, pages %3$s</string>