diff --git a/app/src/main/java/eu/kanade/presentation/library/LibrarySettingsDialog.kt b/app/src/main/java/eu/kanade/presentation/library/LibrarySettingsDialog.kt index d4d0df5c9..f344493ee 100644 --- a/app/src/main/java/eu/kanade/presentation/library/LibrarySettingsDialog.kt +++ b/app/src/main/java/eu/kanade/presentation/library/LibrarySettingsDialog.kt @@ -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(), diff --git a/app/src/main/java/eu/kanade/presentation/library/components/LibraryBadges.kt b/app/src/main/java/eu/kanade/presentation/library/components/LibraryBadges.kt index 76e296390..64ea99bec 100644 --- a/app/src/main/java/eu/kanade/presentation/library/components/LibraryBadges.kt +++ b/app/src/main/java/eu/kanade/presentation/library/components/LibraryBadges.kt @@ -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") diff --git a/app/src/main/java/eu/kanade/presentation/library/components/LibraryComfortableGrid.kt b/app/src/main/java/eu/kanade/presentation/library/components/LibraryComfortableGrid.kt index 828685a31..1539dce84 100644 --- a/app/src/main/java/eu/kanade/presentation/library/components/LibraryComfortableGrid.kt +++ b/app/src/main/java/eu/kanade/presentation/library/components/LibraryComfortableGrid.kt @@ -49,6 +49,7 @@ internal fun LibraryComfortableGrid( coverBadgeStart = { DownloadsBadge(count = libraryItem.downloadCount) UnreadBadge(count = libraryItem.unreadCount) + EstimatedTimeLeftBadge(etaMillis = libraryItem.estimatedTimeLeftMillis) }, coverBadgeEnd = { LanguageBadge( diff --git a/app/src/main/java/eu/kanade/presentation/library/components/LibraryCompactGrid.kt b/app/src/main/java/eu/kanade/presentation/library/components/LibraryCompactGrid.kt index f52a9b66c..cf6a7eaa9 100644 --- a/app/src/main/java/eu/kanade/presentation/library/components/LibraryCompactGrid.kt +++ b/app/src/main/java/eu/kanade/presentation/library/components/LibraryCompactGrid.kt @@ -47,6 +47,7 @@ internal fun LibraryCompactGrid( coverBadgeStart = { DownloadsBadge(count = libraryItem.downloadCount) UnreadBadge(count = libraryItem.unreadCount) + EstimatedTimeLeftBadge(etaMillis = libraryItem.estimatedTimeLeftMillis) }, coverBadgeEnd = { LanguageBadge( diff --git a/app/src/main/java/eu/kanade/presentation/library/components/LibraryList.kt b/app/src/main/java/eu/kanade/presentation/library/components/LibraryList.kt index 2913ec025..03e4d15f1 100644 --- a/app/src/main/java/eu/kanade/presentation/library/components/LibraryList.kt +++ b/app/src/main/java/eu/kanade/presentation/library/components/LibraryList.kt @@ -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, diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsReaderScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsReaderScreen.kt index 8deb1c9af..c6e96a9ea 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsReaderScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsReaderScreen.kt @@ -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, + ), ), ) } diff --git a/app/src/main/java/eu/kanade/presentation/reader/appbars/ReaderAppBars.kt b/app/src/main/java/eu/kanade/presentation/reader/appbars/ReaderAppBars.kt index 5793ec456..f735cd75c 100644 --- a/app/src/main/java/eu/kanade/presentation/reader/appbars/ReaderAppBars.kt +++ b/app/src/main/java/eu/kanade/presentation/reader/appbars/ReaderAppBars.kt @@ -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(200) private val readerBarsFadeAnimationSpec = tween(150) @@ -91,6 +100,9 @@ fun ReaderAppBars( onClickBoostPageHelp: () -> Unit, navBarType: NavBarType, currentPageText: String, + seriesReadingProgress: ReaderViewModel.SeriesReadingProgress?, + showSeriesProgress: Boolean, + showSeriesEta: Boolean, enabledButtons: ImmutableSet, 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, + ) + } +} diff --git a/app/src/main/java/eu/kanade/presentation/reader/settings/GeneralSettingsPage.kt b/app/src/main/java/eu/kanade/presentation/reader/settings/GeneralSettingsPage.kt index 6a0b09c9f..5b223119d 100644 --- a/app/src/main/java/eu/kanade/presentation/reader/settings/GeneralSettingsPage.kt +++ b/app/src/main/java/eu/kanade/presentation/reader/settings/GeneralSettingsPage.kt @@ -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( diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryItem.kt index dd2249019..1b954d66d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryItem.kt @@ -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 --> diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt index 997d996ab..a26888356 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt @@ -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, + ): Map { + 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, + 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? { + 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.applyFilters( trackMap: Map>, trackingFilter: Map, @@ -621,6 +714,7 @@ class LibraryScreenModel( private fun Map>.applySort( favoritesById: Map, + estimatedTimeLeftByMangaId: Map, trackMap: Map>, loggedInTrackerIds: Set, // 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 = emptyList(), val favorites: List = emptyList(), + val estimatedTimeLeftByMangaId: Map = emptyMap(), val tracksMap: Map> = emptyMap(), val loggedInTrackerIds: Set = 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() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt index 97bf25b9e..ccf641db7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt @@ -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( diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt index 1f8650d6f..05fbb6f9f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt @@ -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() + private val chapterReadDurationsPersistent = mutableMapOf() + + private var orderedSeriesChapterIds = emptyList() + private var seriesChapterIndexById = emptyMap() + 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 { + 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, + 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 diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderPreferences.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderPreferences.kt index 2d6fd4025..3f59d3d16 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderPreferences.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderPreferences.kt @@ -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) diff --git a/domain/src/main/java/tachiyomi/domain/library/model/LibrarySortMode.kt b/domain/src/main/java/tachiyomi/domain/library/model/LibrarySortMode.kt index d08d64e63..61138e5d1 100644 --- a/domain/src/main/java/tachiyomi/domain/library/model/LibrarySortMode.kt +++ b/domain/src/main/java/tachiyomi/domain/library/model/LibrarySortMode.kt @@ -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 <-- diff --git a/domain/src/main/java/tachiyomi/domain/library/service/LibraryPreferences.kt b/domain/src/main/java/tachiyomi/domain/library/service/LibraryPreferences.kt index 053debf13..9ac01c852 100644 --- a/domain/src/main/java/tachiyomi/domain/library/service/LibraryPreferences.kt +++ b/domain/src/main/java/tachiyomi/domain/library/service/LibraryPreferences.kt @@ -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) diff --git a/i18n-sy/src/commonMain/moko-resources/base/strings.xml b/i18n-sy/src/commonMain/moko-resources/base/strings.xml index 53029f64d..c50c06a65 100644 --- a/i18n-sy/src/commonMain/moko-resources/base/strings.xml +++ b/i18n-sy/src/commonMain/moko-resources/base/strings.xml @@ -193,6 +193,8 @@ Mark duplicate chapters as read after reading Mark new duplicate chapters as read Automatically mark new chapters as read if it has been read before + Estimated time left + Show estimated time left badge Every 30 minutes Every hour Every 3 hours @@ -323,6 +325,10 @@ Switches which side the seekbar is on Force horizontal seekbar Removes vertical seekbar entirely in favour of horizontal + Show series chapter progress + Show estimated time to series end + Persist ETA data between sessions + When disabled, ETA is only based on the current reading session Smooth Auto Scroll @@ -357,6 +363,9 @@ Copy first page Copy second page Copy combined page + Chapter %1$d/%2$d + ETA %1$s + ETA -- %1$s: %2$s, pages %3$s