diff --git a/app/src/main/java/eu/kanade/presentation/entries/anime/AnimeScreen.kt b/app/src/main/java/eu/kanade/presentation/entries/anime/AnimeScreen.kt index 4e913aa7d0..b0bb848895 100644 --- a/app/src/main/java/eu/kanade/presentation/entries/anime/AnimeScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/entries/anime/AnimeScreen.kt @@ -37,6 +37,7 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -69,13 +70,17 @@ import eu.kanade.presentation.entries.components.MissingItemCountListItem import eu.kanade.presentation.util.formatEpisodeNumber import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource import eu.kanade.tachiyomi.animesource.model.SAnime +import eu.kanade.tachiyomi.data.download.anime.AnimeDownloadProvider import eu.kanade.tachiyomi.data.download.anime.model.AnimeDownload import eu.kanade.tachiyomi.source.anime.getNameForAnimeInfo import eu.kanade.tachiyomi.ui.browse.anime.extension.details.AnimeSourcePreferencesScreen import eu.kanade.tachiyomi.ui.entries.anime.AnimeScreenModel import eu.kanade.tachiyomi.ui.entries.anime.EpisodeList import eu.kanade.tachiyomi.util.system.copyToClipboard +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import tachiyomi.domain.download.service.DownloadPreferences import tachiyomi.domain.entries.anime.model.Anime import tachiyomi.domain.items.episode.model.Episode import tachiyomi.domain.items.episode.service.missingEpisodesCount @@ -90,9 +95,13 @@ import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.util.shouldExpandFAB import tachiyomi.source.local.entries.anime.isLocal +import uy.kohesive.injekt.injectLazy import java.time.Instant import java.util.concurrent.TimeUnit +private val preferences: DownloadPreferences by injectLazy() +private val animeDownloadProvider: AnimeDownloadProvider by injectLazy() + @Composable fun AnimeScreen( state: AnimeScreenModel.State.Success, @@ -512,6 +521,7 @@ private fun AnimeScreenSmallImpl( sharedEpisodeItems( anime = state.anime, + state = state, episodes = listItem, isAnyEpisodeSelected = episodes.fastAny { it.selected }, episodeSwipeStartAction = episodeSwipeStartAction, @@ -791,6 +801,7 @@ fun AnimeScreenLargeImpl( sharedEpisodeItems( anime = state.anime, + state = state, episodes = listItem, isAnyEpisodeSelected = episodes.fastAny { it.selected }, episodeSwipeStartAction = episodeSwipeStartAction, @@ -861,6 +872,7 @@ private fun SharedAnimeBottomActionMenu( private fun LazyListScope.sharedEpisodeItems( anime: Anime, + state: AnimeScreenModel.State.Success, episodes: List, isAnyEpisodeSelected: Boolean, episodeSwipeStartAction: LibraryPreferences.EpisodeSwipeAction, @@ -887,6 +899,24 @@ private fun LazyListScope.sharedEpisodeItems( MissingItemCountListItem(count = episodeItem.count) } is EpisodeList.Item -> { + var fileSizeAsync: Long? by remember { mutableStateOf(episodeItem.fileSize) } + if (episodeItem.downloadState == AnimeDownload.State.DOWNLOADED && + preferences.showEpisodeFileSize().get() && fileSizeAsync == null + ) { + LaunchedEffect(episodeItem, Unit) { + fileSizeAsync = withContext(Dispatchers.IO) { + animeDownloadProvider.getEpisodeFileSize( + episodeItem.episode.name, + episodeItem.episode.url, + episodeItem.episode.scanlator, + state.anime.title, + state.source, + ) + } + episodeItem.fileSize = fileSizeAsync + } + } + AnimeEpisodeListItem( title = if (anime.displayMode == Anime.EPISODE_DISPLAY_NUMBER) { stringResource( @@ -935,6 +965,7 @@ private fun LazyListScope.sharedEpisodeItems( onEpisodeSwipe = { onEpisodeSwipe(episodeItem, it) }, + fileSize = fileSizeAsync, ) } } diff --git a/app/src/main/java/eu/kanade/presentation/entries/anime/components/AnimeEpisodeListItem.kt b/app/src/main/java/eu/kanade/presentation/entries/anime/components/AnimeEpisodeListItem.kt index 55f288f935..b2ae8561b8 100644 --- a/app/src/main/java/eu/kanade/presentation/entries/anime/components/AnimeEpisodeListItem.kt +++ b/app/src/main/java/eu/kanade/presentation/entries/anime/components/AnimeEpisodeListItem.kt @@ -59,6 +59,7 @@ fun AnimeEpisodeListItem( bookmark: Boolean, selected: Boolean, downloadIndicatorEnabled: Boolean, + fileSize: Long?, downloadStateProvider: () -> AnimeDownload.State, downloadProgressProvider: () -> Int, episodeSwipeStartAction: LibraryPreferences.EpisodeSwipeAction, @@ -101,6 +102,7 @@ fun AnimeEpisodeListItem( onLongClick = onLongClick, ) .padding(start = 16.dp, top = 12.dp, end = 8.dp, bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically, ) { Column( modifier = Modifier.weight(1f), @@ -181,6 +183,7 @@ fun AnimeEpisodeListItem( downloadStateProvider = downloadStateProvider, downloadProgressProvider = downloadProgressProvider, onClick = { onDownloadClick?.invoke(it) }, + fileSize = fileSize, ) } } diff --git a/app/src/main/java/eu/kanade/presentation/entries/anime/components/EpisodeDownloadIndicator.kt b/app/src/main/java/eu/kanade/presentation/entries/anime/components/EpisodeDownloadIndicator.kt index 47558a1bd8..b831c148df 100644 --- a/app/src/main/java/eu/kanade/presentation/entries/anime/components/EpisodeDownloadIndicator.kt +++ b/app/src/main/java/eu/kanade/presentation/entries/anime/components/EpisodeDownloadIndicator.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import eu.kanade.presentation.components.ArrowModifier import eu.kanade.presentation.components.DropdownMenu import eu.kanade.presentation.components.IndicatorModifier @@ -37,6 +38,8 @@ import tachiyomi.i18n.MR import tachiyomi.presentation.core.components.material.IconButtonTokens import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.util.secondaryItemAlpha +import java.math.BigDecimal +import java.math.RoundingMode enum class EpisodeDownloadAction { START, @@ -49,6 +52,7 @@ enum class EpisodeDownloadAction { @Composable fun EpisodeDownloadIndicator( enabled: Boolean, + fileSize: Long?, downloadStateProvider: () -> AnimeDownload.State, downloadProgressProvider: () -> Int, onClick: (EpisodeDownloadAction) -> Unit, @@ -60,6 +64,7 @@ fun EpisodeDownloadIndicator( modifier = modifier, onClick = onClick, ) + AnimeDownload.State.QUEUE, AnimeDownload.State.DOWNLOADING -> DownloadingIndicator( enabled = enabled, modifier = modifier, @@ -67,11 +72,14 @@ fun EpisodeDownloadIndicator( downloadProgressProvider = downloadProgressProvider, onClick = onClick, ) + AnimeDownload.State.DOWNLOADED -> DownloadedIndicator( enabled = enabled, modifier = modifier, onClick = onClick, + fileSize, ) + AnimeDownload.State.ERROR -> ErrorIndicator( enabled = enabled, modifier = modifier, @@ -192,8 +200,21 @@ private fun DownloadedIndicator( enabled: Boolean, modifier: Modifier = Modifier, onClick: (EpisodeDownloadAction) -> Unit, + fileSize: Long?, ) { var isMenuExpanded by remember { mutableStateOf(false) } + + if (fileSize != null) { + Text( + text = formatFileSize(fileSize), + maxLines = 1, + style = MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.primary, + fontSize = 12.sp, + ), + ) + } + Box( modifier = modifier .size(IconButtonTokens.StateLayerSize) @@ -223,6 +244,16 @@ private fun DownloadedIndicator( } } +private fun formatFileSize(fileSize: Long): String { + val megaByteSize = fileSize / 1000.0 / 1000.0 + return if (megaByteSize > 900) { + val gigaByteSize = megaByteSize / 1000.0 + "${BigDecimal(gigaByteSize).setScale(2, RoundingMode.HALF_EVEN)} GB" + } else { + "${BigDecimal(megaByteSize).setScale(0, RoundingMode.HALF_EVEN)} MB" + } +} + @Composable private fun ErrorIndicator( enabled: Boolean, diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDownloadScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDownloadScreen.kt index 9f18e214e2..28dffe30ad 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDownloadScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDownloadScreen.kt @@ -80,6 +80,11 @@ object SettingsDownloadScreen : SearchableSettings { pref = downloadPreferences.downloadOnlyOverWifi(), title = stringResource(MR.strings.connected_to_wifi), ), + Preference.PreferenceItem.SwitchPreference( + pref = downloadPreferences.showEpisodeFileSize(), + title = stringResource(MR.strings.show_downloaded_episode_size), + subtitle = stringResource(MR.strings.safe_download_summary), + ), Preference.PreferenceItem.SwitchPreference( pref = downloadPreferences.safeDownload(), title = stringResource(MR.strings.safe_download), diff --git a/app/src/main/java/eu/kanade/presentation/updates/anime/AnimeUpdatesUiItem.kt b/app/src/main/java/eu/kanade/presentation/updates/anime/AnimeUpdatesUiItem.kt index 7530b917c4..e7fde3208f 100644 --- a/app/src/main/java/eu/kanade/presentation/updates/anime/AnimeUpdatesUiItem.kt +++ b/app/src/main/java/eu/kanade/presentation/updates/anime/AnimeUpdatesUiItem.kt @@ -20,8 +20,10 @@ import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -39,8 +41,13 @@ import eu.kanade.presentation.entries.components.DotSeparatorText import eu.kanade.presentation.entries.components.ItemCover import eu.kanade.presentation.util.animateItemFastScroll import eu.kanade.presentation.util.relativeTimeSpanString +import eu.kanade.tachiyomi.data.download.anime.AnimeDownloadProvider import eu.kanade.tachiyomi.data.download.anime.model.AnimeDownload import eu.kanade.tachiyomi.ui.updates.anime.AnimeUpdatesItem +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import tachiyomi.domain.download.service.DownloadPreferences +import tachiyomi.domain.source.anime.service.AnimeSourceManager import tachiyomi.domain.updates.anime.model.AnimeUpdatesWithRelations import tachiyomi.i18n.MR import tachiyomi.presentation.core.components.ListGroupHeader @@ -48,8 +55,13 @@ import tachiyomi.presentation.core.components.material.DISABLED_ALPHA import tachiyomi.presentation.core.components.material.padding import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.util.selectedBackground +import uy.kohesive.injekt.injectLazy import java.util.concurrent.TimeUnit +private val preferences: DownloadPreferences by injectLazy() +private val animeDownloadProvider: AnimeDownloadProvider by injectLazy() +private val animeSourceManager: AnimeSourceManager by injectLazy() + internal fun LazyListScope.animeUpdatesLastUpdatedItem( lastUpdated: Long, ) { @@ -135,6 +147,7 @@ internal fun LazyListScope.animeUpdatesUiItems( }.takeIf { !selectionMode }, downloadStateProvider = updatesItem.downloadStateProvider, downloadProgressProvider = updatesItem.downloadProgressProvider, + updatesItem = updatesItem, ) } } @@ -153,6 +166,7 @@ private fun AnimeUpdatesUiItem( // Download Indicator downloadStateProvider: () -> AnimeDownload.State, downloadProgressProvider: () -> Int, + updatesItem: AnimeUpdatesItem, modifier: Modifier = Modifier, ) { val haptic = LocalHapticFeedback.current @@ -238,12 +252,32 @@ private fun AnimeUpdatesUiItem( } } + var fileSizeAsync: Long? by remember { mutableStateOf(updatesItem.fileSize) } + if (downloadStateProvider() == AnimeDownload.State.DOWNLOADED && + preferences.showEpisodeFileSize().get() && + fileSizeAsync == null + ) { + LaunchedEffect(update, Unit) { + fileSizeAsync = withContext(Dispatchers.IO) { + animeDownloadProvider.getEpisodeFileSize( + update.episodeName, + null, + update.scanlator, + update.animeTitle, + animeSourceManager.getOrStub(update.sourceId), + ) + } + updatesItem.fileSize = fileSizeAsync + } + } + EpisodeDownloadIndicator( enabled = onDownloadEpisode != null, modifier = Modifier.padding(start = 4.dp), downloadStateProvider = downloadStateProvider, downloadProgressProvider = downloadProgressProvider, onClick = { onDownloadEpisode?.invoke(it) }, + fileSize = fileSizeAsync, ) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/anime/AnimeDownloadProvider.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/anime/AnimeDownloadProvider.kt index 215692097f..f71b5a7e32 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/anime/AnimeDownloadProvider.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/anime/AnimeDownloadProvider.kt @@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.data.download.anime import android.content.Context import com.hippo.unifile.UniFile import eu.kanade.tachiyomi.animesource.AnimeSource +import eu.kanade.tachiyomi.util.size import eu.kanade.tachiyomi.util.storage.DiskUtil import logcat.LogPriority import tachiyomi.core.common.i18n.stringResource @@ -12,6 +13,8 @@ import tachiyomi.domain.entries.anime.model.Anime import tachiyomi.domain.items.episode.model.Episode import tachiyomi.domain.storage.service.StorageManager import tachiyomi.i18n.MR +import tachiyomi.source.local.entries.anime.isLocal +import tachiyomi.source.local.io.anime.LocalAnimeSourceFileSystem import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get @@ -24,6 +27,7 @@ import uy.kohesive.injekt.api.get class AnimeDownloadProvider( private val context: Context, private val storageManager: StorageManager = Injekt.get(), + private val localFileSystem: LocalAnimeSourceFileSystem = Injekt.get(), ) { private val downloadsDir: UniFile? @@ -183,4 +187,29 @@ class AnimeDownloadProvider( val oldEpisodeDirName = getOldEpisodeDirName(episodeName, episodeScanlator) return listOf(episodeDirName, oldEpisodeDirName) } + + /** + * Returns an episode file size in bytes. + * Returns null if the episode is not found in expected location + * + * @param episodeName the name of the episode to query. + * @param episodeScanlator scanlator of the episode to query + * @param animeTitle the title of the anime + * @param animeSource the source of the anime + */ + fun getEpisodeFileSize( + episodeName: String, + episodeUrl: String?, + episodeScanlator: String?, + animeTitle: String, + animeSource: AnimeSource?, + ): Long? { + if (animeSource == null) return null + return if (animeSource.isLocal()) { + val (animeDirName, episodeDirName) = episodeUrl?.split('/', limit = 2) ?: return null + localFileSystem.getBaseDirectory()?.findFile(animeDirName)?.findFile(episodeDirName)?.size() + } else { + findEpisodeDir(episodeName, episodeScanlator, animeTitle, animeSource)?.size() + } + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/entries/anime/AnimeScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/entries/anime/AnimeScreenModel.kt index 964eb98e0f..c65811b9d1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/entries/anime/AnimeScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/entries/anime/AnimeScreenModel.kt @@ -1216,6 +1216,7 @@ sealed class EpisodeList { val episode: Episode, val downloadState: AnimeDownload.State, val downloadProgress: Int, + var fileSize: Long? = null, val selected: Boolean = false, ) : EpisodeList() { val id = episode.id diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/updates/anime/AnimeUpdatesScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/updates/anime/AnimeUpdatesScreenModel.kt index daec3dac04..17ea6d846e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/updates/anime/AnimeUpdatesScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/updates/anime/AnimeUpdatesScreenModel.kt @@ -425,4 +425,5 @@ data class AnimeUpdatesItem( val downloadStateProvider: () -> AnimeDownload.State, val downloadProgressProvider: () -> Int, val selected: Boolean = false, + var fileSize: Long? = null, ) diff --git a/domain/src/main/java/tachiyomi/domain/download/service/DownloadPreferences.kt b/domain/src/main/java/tachiyomi/domain/download/service/DownloadPreferences.kt index 46026033a2..11393938fa 100644 --- a/domain/src/main/java/tachiyomi/domain/download/service/DownloadPreferences.kt +++ b/domain/src/main/java/tachiyomi/domain/download/service/DownloadPreferences.kt @@ -6,6 +6,8 @@ class DownloadPreferences( private val preferenceStore: PreferenceStore, ) { + fun showEpisodeFileSize() = preferenceStore.getBoolean("pref_downloaded_episode_size", true) + fun downloadOnlyOverWifi() = preferenceStore.getBoolean( "pref_download_only_over_wifi_key", true, diff --git a/i18n/src/commonMain/moko-resources/base/strings.xml b/i18n/src/commonMain/moko-resources/base/strings.xml index 9710a4a924..00aaf9b56f 100644 --- a/i18n/src/commonMain/moko-resources/base/strings.xml +++ b/i18n/src/commonMain/moko-resources/base/strings.xml @@ -1317,4 +1317,5 @@ Number of threads to use for downloading, might get your IP blocked if too high, usually 4 is a good number to avoid heavy load on source servers. Download speed limit Set to 0 to disable the speed limit. + Show downloaded episode size diff --git a/settings.gradle.kts b/settings.gradle.kts index 90f8b4de0e..338b535cb4 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -8,10 +8,10 @@ pluginManagement { } } repositories { + maven(url = "https://www.jitpack.io") gradlePluginPortal() google() mavenCentral() - maven(url = "https://www.jitpack.io") } }