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_chapter_fetch_date to LibrarySort.Type.ChapterFetchDate,
MR.strings.action_sort_date_added to LibrarySort.Type.DateAdded, MR.strings.action_sort_date_added to LibrarySort.Type.DateAdded,
trackerMeanPair, trackerMeanPair,
SYMR.strings.action_sort_estimated_time_left to LibrarySort.Type.EstimatedTimeLeft,
// SY --> // SY -->
tagSortPair, tagSortPair,
// SY <-- // SY <--
@ -365,6 +366,10 @@ private fun ColumnScope.DisplayPage(
label = stringResource(MR.strings.action_display_unread_badge), label = stringResource(MR.strings.action_display_unread_badge),
pref = screenModel.libraryPreferences.unreadBadge(), pref = screenModel.libraryPreferences.unreadBadge(),
) )
CheckboxItem(
label = stringResource(SYMR.strings.action_display_estimated_time_left_badge),
pref = screenModel.libraryPreferences.estimatedTimeLeftBadge(),
)
CheckboxItem( CheckboxItem(
label = stringResource(MR.strings.action_display_local_badge), label = stringResource(MR.strings.action_display_local_badge),
pref = screenModel.libraryPreferences.localBadge(), 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.material.icons.outlined.LocalLibrary
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color 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.domain.source.model.icon
import eu.kanade.presentation.theme.TachiyomiPreviewTheme import eu.kanade.presentation.theme.TachiyomiPreviewTheme
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.presentation.util.toDurationString
import tachiyomi.domain.source.model.Source import tachiyomi.domain.source.model.Source
import tachiyomi.presentation.core.components.Badge import tachiyomi.presentation.core.components.Badge
import tachiyomi.presentation.core.components.BadgeGroup import tachiyomi.presentation.core.components.BadgeGroup
import tachiyomi.source.local.isLocal import tachiyomi.source.local.isLocal
import kotlin.time.DurationUnit
import kotlin.time.toDuration
@Composable @Composable
internal fun DownloadsBadge(count: Long) { 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 @Composable
internal fun LanguageBadge( internal fun LanguageBadge(
isLocal: Boolean, isLocal: Boolean,
@ -121,6 +141,7 @@ private fun BadgePreview() {
BadgeGroup { BadgeGroup {
DownloadsBadge(count = 10) DownloadsBadge(count = 10)
UnreadBadge(count = 10) UnreadBadge(count = 10)
EstimatedTimeLeftBadge(etaMillis = 4_500_000)
LanguageBadge(isLocal = true, sourceLanguage = "en") LanguageBadge(isLocal = true, sourceLanguage = "en")
LanguageBadge(isLocal = false, sourceLanguage = "en", useLangIcon = false) LanguageBadge(isLocal = false, sourceLanguage = "en", useLangIcon = false)
LanguageBadge(isLocal = false, sourceLanguage = "vi") LanguageBadge(isLocal = false, sourceLanguage = "vi")

View file

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

View file

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

View file

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

View file

@ -121,6 +121,7 @@ object SettingsReaderScreen : SearchableSettings {
private fun getDisplayGroup(readerPreferences: ReaderPreferences): Preference.PreferenceGroup { private fun getDisplayGroup(readerPreferences: ReaderPreferences): Preference.PreferenceGroup {
val fullscreenPref = readerPreferences.fullscreen() val fullscreenPref = readerPreferences.fullscreen()
val fullscreen by fullscreenPref.collectAsState() val fullscreen by fullscreenPref.collectAsState()
val showSeriesEta by readerPreferences.showSeriesEta().collectAsState()
return Preference.PreferenceGroup( return Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_category_display), title = stringResource(MR.strings.pref_category_display),
preferenceItems = persistentListOf( preferenceItems = persistentListOf(
@ -158,6 +159,20 @@ object SettingsReaderScreen : SearchableSettings {
preference = readerPreferences.showPageNumber(), preference = readerPreferences.showPageNumber(),
title = stringResource(MR.strings.pref_show_page_number), 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.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight 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.padding
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import eu.kanade.presentation.reader.components.ChapterNavigator 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.ReaderOrientation
import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode
import eu.kanade.tachiyomi.ui.reader.viewer.Viewer import eu.kanade.tachiyomi.ui.reader.viewer.Viewer
import eu.kanade.tachiyomi.ui.reader.viewer.pager.R2LPagerViewer import eu.kanade.tachiyomi.ui.reader.viewer.pager.R2LPagerViewer
import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.ImmutableSet
import tachiyomi.i18n.sy.SYMR
import tachiyomi.presentation.core.components.material.padding 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 readerBarsSlideAnimationSpec = tween<IntOffset>(200)
private val readerBarsFadeAnimationSpec = tween<Float>(150) private val readerBarsFadeAnimationSpec = tween<Float>(150)
@ -91,6 +100,9 @@ fun ReaderAppBars(
onClickBoostPageHelp: () -> Unit, onClickBoostPageHelp: () -> Unit,
navBarType: NavBarType, navBarType: NavBarType,
currentPageText: String, currentPageText: String,
seriesReadingProgress: ReaderViewModel.SeriesReadingProgress?,
showSeriesProgress: Boolean,
showSeriesEta: Boolean,
enabledButtons: ImmutableSet<String>, enabledButtons: ImmutableSet<String>,
currentReadingMode: ReadingMode, currentReadingMode: ReadingMode,
dualPageSplitEnabled: Boolean, dualPageSplitEnabled: Boolean,
@ -250,6 +262,11 @@ fun ReaderAppBars(
// SY <-- // SY <--
) )
} }
ReaderSeriesProgressInfo(
progress = seriesReadingProgress,
showSeriesProgress = showSeriesProgress,
showSeriesEta = showSeriesEta,
)
ReaderBottomBar( ReaderBottomBar(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .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(), 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 --> // SY -->
val forceHorizontalSeekbar by screenModel.preferences.forceHorizontalSeekbar().collectAsState() val forceHorizontalSeekbar by screenModel.preferences.forceHorizontalSeekbar().collectAsState()
CheckboxItem( CheckboxItem(

View file

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

View file

@ -98,6 +98,8 @@ import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
import tachiyomi.domain.chapter.interactor.GetMergedChaptersByMangaId import tachiyomi.domain.chapter.interactor.GetMergedChaptersByMangaId
import tachiyomi.domain.chapter.model.Chapter import tachiyomi.domain.chapter.model.Chapter
import tachiyomi.domain.history.interactor.GetNextChapters 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.LibraryDisplayMode
import tachiyomi.domain.library.model.LibraryGroup import tachiyomi.domain.library.model.LibraryGroup
import tachiyomi.domain.library.model.LibraryManga import tachiyomi.domain.library.model.LibraryManga
@ -148,6 +150,8 @@ class LibraryScreenModel(
private val exhPreferences: ExhPreferences = Injekt.get(), private val exhPreferences: ExhPreferences = Injekt.get(),
private val sourcePreferences: SourcePreferences = Injekt.get(), private val sourcePreferences: SourcePreferences = Injekt.get(),
private val getMergedMangaById: GetMergedMangaById = 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 getTracks: GetTracks = Injekt.get(),
private val getIdsOfFavoriteMangaWithMetadata: GetIdsOfFavoriteMangaWithMetadata = Injekt.get(), private val getIdsOfFavoriteMangaWithMetadata: GetIdsOfFavoriteMangaWithMetadata = Injekt.get(),
private val getSearchTags: GetSearchTags = Injekt.get(), private val getSearchTags: GetSearchTags = Injekt.get(),
@ -190,8 +194,19 @@ class LibraryScreenModel(
), ),
// KMK <-- // KMK <--
getLibraryItemPreferencesFlow(), getLibraryItemPreferencesFlow(),
) { (searchQuery, categories, favorites), (tracksMap, trackingFilters), (includedCategories, excludedCategories), itemPreferences -> getTotalReadDuration.subscribe().distinctUntilChanged(),
val filteredFavorites = favorites ) { (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( .applyFilters(
tracksMap, tracksMap,
trackingFilters, trackingFilters,
@ -219,6 +234,7 @@ class LibraryScreenModel(
isInitialized = true, isInitialized = true,
categories = categories, categories = categories,
favorites = filteredFavorites, favorites = filteredFavorites,
estimatedTimeLeftByMangaId = estimatedTimeLeftByMangaId,
tracksMap = tracksMap, tracksMap = tracksMap,
loggedInTrackerIds = trackingFilters.keys, loggedInTrackerIds = trackingFilters.keys,
) )
@ -275,6 +291,7 @@ class LibraryScreenModel(
) )
.applySort( .applySort(
data.favoritesById, data.favoritesById,
data.estimatedTimeLeftByMangaId,
data.tracksMap, data.tracksMap,
data.loggedInTrackerIds, data.loggedInTrackerIds,
// SY --> // SY -->
@ -421,6 +438,82 @@ class LibraryScreenModel(
// KMK <-- // 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( private suspend fun List<LibraryItem>.applyFilters(
trackMap: Map<Long, List<Track>>, trackMap: Map<Long, List<Track>>,
trackingFilter: Map<Long, TriState>, trackingFilter: Map<Long, TriState>,
@ -621,6 +714,7 @@ class LibraryScreenModel(
private fun Map<Category, List</* LibraryItem */ Long>>.applySort( private fun Map<Category, List</* LibraryItem */ Long>>.applySort(
favoritesById: Map<Long, LibraryItem>, favoritesById: Map<Long, LibraryItem>,
estimatedTimeLeftByMangaId: Map<Long, Long?>,
trackMap: Map<Long, List<Track>>, trackMap: Map<Long, List<Track>>,
loggedInTrackerIds: Set<Long>, loggedInTrackerIds: Set<Long>,
// SY --> // SY -->
@ -700,6 +794,13 @@ class LibraryScreenModel(
val item2Score = trackerScores[manga2.id] ?: defaultTrackerScoreSortValue val item2Score = trackerScores[manga2.id] ?: defaultTrackerScoreSortValue
item1Score.compareTo(item2Score) item1Score.compareTo(item2Score)
} }
LibrarySort.Type.EstimatedTimeLeft -> {
compareEstimatedTimeLeft(
estimatedTimeLeftByMangaId[manga1.id],
estimatedTimeLeftByMangaId[manga2.id],
sort.isAscending,
)
}
LibrarySort.Type.Random -> { LibrarySort.Type.Random -> {
error("Why Are We Still Here? Just To Suffer?") error("Why Are We Still Here? Just To Suffer?")
} }
@ -730,7 +831,13 @@ class LibraryScreenModel(
// SY --> // SY -->
val comparator = sort.comparator() val comparator = sort.comparator()
// SY <-- // 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) .thenComparator(sortAlphabetically)
manga.sortedWith(comparator).map { it.id } manga.sortedWith(comparator).map { it.id }
@ -743,6 +850,7 @@ class LibraryScreenModel(
libraryPreferences.unreadBadge().changes(), libraryPreferences.unreadBadge().changes(),
libraryPreferences.localBadge().changes(), libraryPreferences.localBadge().changes(),
libraryPreferences.languageBadge().changes(), libraryPreferences.languageBadge().changes(),
libraryPreferences.estimatedTimeLeftBadge().changes(),
libraryPreferences.autoUpdateMangaRestrictions().changes(), libraryPreferences.autoUpdateMangaRestrictions().changes(),
preferences.downloadedOnly().changes(), preferences.downloadedOnly().changes(),
@ -766,21 +874,22 @@ class LibraryScreenModel(
unreadBadge = it[1] as Boolean, unreadBadge = it[1] as Boolean,
localBadge = it[2] as Boolean, localBadge = it[2] as Boolean,
languageBadge = it[3] as Boolean, languageBadge = it[3] as Boolean,
skipOutsideReleasePeriod = LibraryPreferences.MANGA_OUTSIDE_RELEASE_PERIOD in (it[4] as Set<*>), estimatedTimeLeftBadge = it[4] as Boolean,
globalFilterDownloaded = it[5] as Boolean, skipOutsideReleasePeriod = LibraryPreferences.MANGA_OUTSIDE_RELEASE_PERIOD in (it[5] as Set<*>),
filterDownloaded = it[6] as TriState, globalFilterDownloaded = it[6] as Boolean,
filterUnread = it[7] as TriState, filterDownloaded = it[7] as TriState,
filterStarted = it[8] as TriState, filterUnread = it[8] as TriState,
filterBookmarked = it[9] as TriState, filterStarted = it[9] as TriState,
filterCompleted = it[10] as TriState, filterBookmarked = it[10] as TriState,
filterIntervalCustom = it[11] as TriState, filterCompleted = it[11] as TriState,
filterIntervalCustom = it[12] as TriState,
// SY --> // SY -->
filterLewd = it[12] as TriState, filterLewd = it[13] as TriState,
// SY <-- // SY <--
// KMK --> // KMK -->
sourceBadge = it[13] as Boolean, sourceBadge = it[14] as Boolean,
useLangIcon = it[14] as Boolean, useLangIcon = it[15] as Boolean,
filterCategories = it[15] as Boolean, filterCategories = it[16] as Boolean,
) )
} }
} }
@ -1625,6 +1734,7 @@ class LibraryScreenModel(
val unreadBadge: Boolean, val unreadBadge: Boolean,
val localBadge: Boolean, val localBadge: Boolean,
val languageBadge: Boolean, val languageBadge: Boolean,
val estimatedTimeLeftBadge: Boolean,
// KMK --> // KMK -->
val useLangIcon: Boolean, val useLangIcon: Boolean,
val sourceBadge: Boolean, val sourceBadge: Boolean,
@ -1651,6 +1761,7 @@ class LibraryScreenModel(
val isInitialized: Boolean = false, val isInitialized: Boolean = false,
val categories: List<Category> = emptyList(), val categories: List<Category> = emptyList(),
val favorites: List<LibraryItem> = emptyList(), val favorites: List<LibraryItem> = emptyList(),
val estimatedTimeLeftByMangaId: Map<Long, Long?> = emptyMap(),
val tracksMap: Map</* Manga */ Long, List<Track>> = emptyMap(), val tracksMap: Map</* Manga */ Long, List<Track>> = emptyMap(),
val loggedInTrackerIds: Set<Long> = emptySet(), val loggedInTrackerIds: Set<Long> = emptySet(),
) { ) {
@ -1768,6 +1879,10 @@ 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>()

View file

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

View file

@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.ui.reader
import android.app.Application import android.app.Application
import android.net.Uri import android.net.Uri
import android.os.SystemClock
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.annotation.IntRange import androidx.annotation.IntRange
import androidx.compose.runtime.Immutable 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.model.ChapterUpdate
import tachiyomi.domain.chapter.service.getChapterSort import tachiyomi.domain.chapter.service.getChapterSort
import tachiyomi.domain.download.service.DownloadPreferences import tachiyomi.domain.download.service.DownloadPreferences
import tachiyomi.domain.history.interactor.GetHistory
import tachiyomi.domain.history.interactor.GetNextChapters import tachiyomi.domain.history.interactor.GetNextChapters
import tachiyomi.domain.history.interactor.UpsertHistory import tachiyomi.domain.history.interactor.UpsertHistory
import tachiyomi.domain.history.model.HistoryUpdate import tachiyomi.domain.history.model.HistoryUpdate
@ -104,7 +106,6 @@ import tachiyomi.domain.source.service.SourceManager
import tachiyomi.source.local.isLocal import tachiyomi.source.local.isLocal
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.time.Instant
import java.util.Date import java.util.Date
/** /**
@ -125,6 +126,7 @@ class ReaderViewModel @JvmOverloads constructor(
private val getManga: GetManga = Injekt.get(), private val getManga: GetManga = Injekt.get(),
private val getChaptersByMangaId: GetChaptersByMangaId = Injekt.get(), private val getChaptersByMangaId: GetChaptersByMangaId = Injekt.get(),
private val getNextChapters: GetNextChapters = Injekt.get(), private val getNextChapters: GetNextChapters = Injekt.get(),
private val getHistory: GetHistory = Injekt.get(),
private val upsertHistory: UpsertHistory = Injekt.get(), private val upsertHistory: UpsertHistory = Injekt.get(),
private val updateChapter: UpdateChapter = Injekt.get(), private val updateChapter: UpdateChapter = Injekt.get(),
private val setMangaViewerFlags: SetMangaViewerFlags = Injekt.get(), private val setMangaViewerFlags: SetMangaViewerFlags = Injekt.get(),
@ -246,6 +248,12 @@ class ReaderViewModel @JvmOverloads constructor(
*/ */
private var chapterReadStartTime: Long? = null 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 var chapterToDownload: Download? = null
private val unfilteredChapterList by lazy { private val unfilteredChapterList by lazy {
@ -488,6 +496,7 @@ class ReaderViewModel @JvmOverloads constructor(
page, page,
// SY <-- // SY <--
) )
initializeSeriesProgressState()
Result.success(true) Result.success(true)
} else { } else {
// Unlikely but okay // Unlikely but okay
@ -557,6 +566,7 @@ class ReaderViewModel @JvmOverloads constructor(
) )
} }
} }
updateSeriesProgressState()
return newChapters return newChapters
} }
@ -818,6 +828,7 @@ class ReaderViewModel @JvmOverloads constructor(
mutableState.update { mutableState.update {
it.copy(currentPage = pageIndex + 1) it.copy(currentPage = pageIndex + 1)
} }
updateSeriesProgressState()
readerChapter.requestedPage = pageIndex readerChapter.requestedPage = pageIndex
chapterPageIndex = pageIndex chapterPageIndex = pageIndex
@ -846,6 +857,7 @@ class ReaderViewModel @JvmOverloads constructor(
lastPageRead = readerChapter.chapter.last_page_read.toLong(), lastPageRead = readerChapter.chapter.last_page_read.toLong(),
), ),
) )
updateSeriesProgressState()
// SY --> // SY -->
// Check if syncing is enabled for chapter open: // Check if syncing is enabled for chapter open:
@ -897,7 +909,8 @@ class ReaderViewModel @JvmOverloads constructor(
} }
fun restartReadTimer() { fun restartReadTimer() {
chapterReadStartTime = Instant.now().toEpochMilli() chapterReadStartTime = SystemClock.elapsedRealtime()
updateSeriesProgressState()
} }
/** /**
@ -909,13 +922,215 @@ class ReaderViewModel @JvmOverloads constructor(
val chapterId = readerChapter.chapter.id!! val chapterId = readerChapter.chapter.id!!
val endTime = Date() 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)) upsertHistory.await(HistoryUpdate(chapterId, endTime, sessionReadDuration))
if (sessionReadDuration > 0) {
chapterReadDurationsSession.merge(chapterId, sessionReadDuration, Long::plus)
chapterReadDurationsPersistent.merge(chapterId, sessionReadDuration, Long::plus)
}
chapterReadStartTime = null 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. * 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 @Immutable
data class State( data class State(
val manga: Manga? = null, val manga: Manga? = null,
@ -1496,6 +1718,7 @@ class ReaderViewModel @JvmOverloads constructor(
val autoScroll: Boolean = false, val autoScroll: Boolean = false,
val isAutoScrollEnabled: Boolean = false, val isAutoScrollEnabled: Boolean = false,
val ehAutoscrollFreq: String = "", val ehAutoscrollFreq: String = "",
val seriesReadingProgress: SeriesReadingProgress? = null,
// SY <-- // SY <--
) { ) {
val currentChapter: ReaderChapter? val currentChapter: ReaderChapter?
@ -1505,6 +1728,14 @@ class ReaderViewModel @JvmOverloads constructor(
get() = currentChapter?.pages?.size ?: -1 get() = currentChapter?.pages?.size ?: -1
} }
@Immutable
data class SeriesReadingProgress(
val currentChapter: Int,
val totalChapters: Int,
val remainingChapters: Int,
val etaMillis: Long?,
)
sealed interface Dialog { sealed interface Dialog {
data object Loading : Dialog data object Loading : Dialog
data object Settings : Dialog data object Settings : Dialog

View file

@ -35,6 +35,12 @@ class ReaderPreferences(
fun showPageNumber() = preferenceStore.getBoolean("pref_show_page_number_key", true) 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 showReadingMode() = preferenceStore.getBoolean("pref_show_reading_mode", true)
fun fullscreen() = preferenceStore.getBoolean("fullscreen", true) fun fullscreen() = preferenceStore.getBoolean("fullscreen", true)

View file

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

View file

@ -145,6 +145,8 @@ class LibraryPreferences(
fun unreadBadge() = preferenceStore.getBoolean("display_unread_badge", true) 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 localBadge() = preferenceStore.getBoolean("display_local_badge", true)
fun languageBadge() = preferenceStore.getBoolean("display_language_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_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">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="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_30min">Every 30 minutes</string>
<string name="update_1hour">Every hour</string> <string name="update_1hour">Every hour</string>
<string name="update_3hour">Every 3 hours</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_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">Force horizontal seekbar</string>
<string name="pref_force_horz_seekbar_summary">Removes vertical seekbar entirely in favour of horizontal</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> <string name="pref_smooth_scroll">Smooth Auto Scroll</string>
<!-- Reader --> <!-- Reader -->
@ -357,6 +363,9 @@
<string name="action_copy_first_page">Copy first page</string> <string name="action_copy_first_page">Copy first page</string>
<string name="action_copy_second_page">Copy second page</string> <string name="action_copy_second_page">Copy second page</string>
<string name="action_copy_combined_page">Copy combined 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 --> <!-- Reader Sharing -->
<string name="share_pages_info">%1$s: %2$s, pages %3$s</string> <string name="share_pages_info">%1$s: %2$s, pages %3$s</string>