feat(reader): restore series ETA tracking and estimated time left
Some checks failed
Forgejo Release Builder / release (push) Failing after 5m28s
Some checks failed
Forgejo Release Builder / release (push) Failing after 5m28s
This commit is contained in:
parent
af860b5f9d
commit
48436e3c46
16 changed files with 528 additions and 18 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ internal fun LibraryComfortableGrid(
|
|||
coverBadgeStart = {
|
||||
DownloadsBadge(count = libraryItem.downloadCount)
|
||||
UnreadBadge(count = libraryItem.unreadCount)
|
||||
EstimatedTimeLeftBadge(etaMillis = libraryItem.estimatedTimeLeftMillis)
|
||||
},
|
||||
coverBadgeEnd = {
|
||||
LanguageBadge(
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ internal fun LibraryCompactGrid(
|
|||
coverBadgeStart = {
|
||||
DownloadsBadge(count = libraryItem.downloadCount)
|
||||
UnreadBadge(count = libraryItem.unreadCount)
|
||||
EstimatedTimeLeftBadge(etaMillis = libraryItem.estimatedTimeLeftMillis)
|
||||
},
|
||||
coverBadgeEnd = {
|
||||
LanguageBadge(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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>()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,11 +922,213 @@ 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))
|
||||
chapterReadStartTime = null
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 <--
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue