Merge branch 'master' into MR

This commit is contained in:
LuftVerbot 2023-11-02 20:58:42 +01:00
commit 8f08a246a2
23 changed files with 1037 additions and 32 deletions

View file

@ -60,7 +60,10 @@ fun TriStateItem(
},
)
.fillMaxWidth()
.padding(horizontal = SettingsItemsPaddings.Horizontal, vertical = SettingsItemsPaddings.Vertical),
.padding(
horizontal = SettingsItemsPaddings.Horizontal,
vertical = SettingsItemsPaddings.Vertical,
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(24.dp),
) {
@ -91,15 +94,18 @@ fun TriStateItem(
}
@Composable
fun SelectItem(
fun <T> SelectItem(
label: String,
options: Array<out Any?>,
options: Array<T>,
selectedIndex: Int,
modifier: Modifier = Modifier,
onSelect: (Int) -> Unit,
toString: (T) -> String = { it.toString() },
) {
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
modifier = modifier,
expanded = expanded,
onExpandedChange = { expanded = !expanded },
) {
@ -107,9 +113,12 @@ fun SelectItem(
modifier = Modifier
.menuAnchor()
.fillMaxWidth()
.padding(horizontal = SettingsItemsPaddings.Horizontal, vertical = SettingsItemsPaddings.Vertical),
.padding(
horizontal = SettingsItemsPaddings.Horizontal,
vertical = SettingsItemsPaddings.Vertical,
),
label = { Text(text = label) },
value = options[selectedIndex].toString(),
value = toString(options[selectedIndex]),
onValueChange = {},
readOnly = true,
singleLine = true,
@ -126,9 +135,9 @@ fun SelectItem(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
options.forEachIndexed { index, text ->
options.forEachIndexed { index, option ->
DropdownMenuItem(
text = { Text(text.toString()) },
text = { Text(toString(option)) },
onClick = {
onSelect(index)
expanded = false

View file

@ -19,6 +19,7 @@ import androidx.compose.material.icons.outlined.Label
import androidx.compose.material.icons.outlined.QueryStats
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.SettingsBackupRestore
import androidx.compose.material.icons.outlined.Storage
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
@ -50,6 +51,7 @@ fun MoreScreen(
onClickDownloadQueue: () -> Unit,
onClickCategories: () -> Unit,
onClickStats: () -> Unit,
onClickStorage: () -> Unit,
onClickBackupAndRestore: () -> Unit,
onClickSettings: () -> Unit,
onClickAbout: () -> Unit,
@ -165,6 +167,13 @@ fun MoreScreen(
onPreferenceClick = onClickStats,
)
}
item {
TextPreferenceWidget(
title = stringResource(R.string.label_storage),
icon = Icons.Outlined.Storage,
onPreferenceClick = onClickStorage,
)
}
item {
TextPreferenceWidget(
title = stringResource(R.string.label_backup),

View file

@ -0,0 +1,72 @@
package eu.kanade.presentation.more.storage
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.layout.Layout
import eu.kanade.tachiyomi.util.toSize
@Composable
fun CumulativeStorage(
items: List<StorageItem>,
modifier: Modifier = Modifier,
borderWidth: Float = 15f,
) {
val totalSize = remember(items) {
items.sumOf { it.size }.toFloat()
}
val totalSizeString = remember(totalSize) {
totalSize.toLong().toSize()
}
Layout(
modifier = modifier,
content = {
Canvas(
modifier = Modifier.aspectRatio(1f),
onDraw = {
val totalAngle = 180f
var currentAngle = 0f
rotate(180f) {
for (item in items) {
val itemAngle = (item.size / totalSize) * totalAngle
drawArc(
color = item.color,
startAngle = currentAngle,
sweepAngle = itemAngle,
useCenter = false,
style = Stroke(width = borderWidth, cap = StrokeCap.Round),
)
currentAngle += itemAngle
}
}
},
)
Text(
text = totalSizeString,
style = MaterialTheme.typography.displaySmall,
)
},
measurePolicy = { measurables, constraints ->
val placeables = measurables.map { measurable ->
measurable.measure(constraints.copy(minWidth = 0, minHeight = 0))
}
val canvas = placeables.first()
val text = placeables.last()
// use half the height of the canvas to avoid too much extra space
layout(constraints.maxWidth, canvas.height / 2) {
canvas.placeRelative(0, 0)
text.placeRelative(
(canvas.width / 2) - (text.width / 2),
(canvas.height / 4) - (text.height / 2),
)
}
},
)
}

View file

@ -0,0 +1,40 @@
package eu.kanade.presentation.more.storage
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import eu.kanade.presentation.components.SelectItem
import eu.kanade.tachiyomi.R
import tachiyomi.domain.category.model.Category
@Composable
fun SelectStorageCategory(
selectedCategory: Category,
categories: List<Category>,
modifier: Modifier = Modifier,
onCategorySelected: (Category) -> Unit,
) {
val all = stringResource(R.string.label_all)
val default = stringResource(R.string.label_default)
val mappedCategories = remember(categories) {
categories.map {
when (it.id) {
-1L -> it.copy(name = all)
Category.UNCATEGORIZED_ID -> it.copy(name = default)
else -> it
}
}.toTypedArray()
}
SelectItem(
modifier = modifier,
label = stringResource(R.string.label_category),
selectedIndex = mappedCategories.indexOfFirst { it.id == selectedCategory.id },
options = mappedCategories,
onSelect = { index ->
onCategorySelected(mappedCategories[index])
},
toString = { it.name },
)
}

View file

@ -0,0 +1,198 @@
package eu.kanade.presentation.more.storage
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import eu.kanade.presentation.entries.ItemCover
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.toSize
import tachiyomi.presentation.core.components.material.padding
data class StorageItem(
val id: Long,
val title: String,
val size: Long,
val thumbnail: String?,
val entriesCount: Int,
val color: Color,
)
@Composable
fun StorageItem(
item: StorageItem,
isManga: Boolean,
modifier: Modifier = Modifier,
onDelete: (Long) -> Unit,
) {
val pluralCount = if (isManga) R.plurals.manga_num_chapters else R.plurals.anime_num_episodes
var showDeleteDialog by remember {
mutableStateOf(false)
}
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.medium),
verticalAlignment = Alignment.CenterVertically,
content = {
ItemCover.Square(
modifier = Modifier.height(48.dp),
data = item.thumbnail,
contentDescription = item.title,
)
Column(
modifier = Modifier.weight(1f),
content = {
Text(
text = item.title,
style = MaterialTheme.typography.bodyMedium,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.W700,
maxLines = 1,
)
Row(
verticalAlignment = Alignment.CenterVertically,
content = {
Box(
modifier = Modifier
.background(item.color, CircleShape)
.size(12.dp),
)
Spacer(Modifier.width(MaterialTheme.padding.small))
Text(
text = item.size.toSize(),
style = MaterialTheme.typography.bodySmall,
)
Box(
modifier = Modifier
.padding(horizontal = MaterialTheme.padding.small / 2)
.background(MaterialTheme.colorScheme.onSurface, CircleShape)
.size(MaterialTheme.padding.small / 2),
)
Text(
text = pluralStringResource(
id = pluralCount,
count = item.entriesCount,
item.entriesCount,
),
style = MaterialTheme.typography.bodySmall,
)
},
)
},
)
IconButton(
onClick = {
showDeleteDialog = true
},
content = {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = stringResource(R.string.action_delete),
)
},
)
},
)
if (showDeleteDialog) {
ItemDeleteDialog(
title = item.title,
isManga = isManga,
onDismissRequest = { showDeleteDialog = false },
onDelete = {
onDelete(item.id)
},
)
}
}
@Composable
private fun ItemDeleteDialog(
title: String,
isManga: Boolean,
onDismissRequest: () -> Unit,
onDelete: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismissRequest,
confirmButton = {
TextButton(
onClick = {
onDelete()
onDismissRequest()
},
content = {
Text(text = stringResource(android.R.string.ok))
},
)
},
dismissButton = {
TextButton(
onClick = onDismissRequest,
content = {
Text(text = stringResource(R.string.action_cancel))
},
)
},
title = {
Text(
text = stringResource(
if (isManga) R.string.delete_downloads_for_manga else R.string.delete_downloads_for_anime,
),
)
},
text = {
Text(
text = stringResource(R.string.delete_confirmation, title),
)
},
)
}
@Preview(showBackground = true)
@Composable
private fun StorageItemPreview() {
StorageItem(
item = StorageItem(
id = 0L,
title = "Manga Title",
size = 123456789L,
thumbnail = null,
entriesCount = 123,
color = Color.Red,
),
isManga = true,
onDelete = {
},
)
}

View file

@ -0,0 +1,196 @@
package eu.kanade.presentation.more.storage
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import eu.kanade.presentation.util.isTabletUi
import tachiyomi.domain.category.model.Category
import tachiyomi.presentation.core.components.LazyColumn
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.screens.LoadingScreen
import kotlin.random.Random
@Composable
fun StorageScreenContent(
state: StorageScreenState,
isManga: Boolean,
modifier: Modifier = Modifier,
contentPadding: PaddingValues,
onCategorySelected: (Category) -> Unit,
onDelete: (Long) -> Unit,
) {
when (state) {
is StorageScreenState.Loading -> {
LoadingScreen(modifier)
}
is StorageScreenState.Success -> {
@Composable
fun Info(modifier: Modifier = Modifier) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
content = {
SelectStorageCategory(
selectedCategory = state.selectedCategory,
categories = state.categories,
onCategorySelected = onCategorySelected,
)
CumulativeStorage(
modifier = Modifier
.padding(
horizontal = MaterialTheme.padding.small,
vertical = MaterialTheme.padding.medium,
)
.run {
if (isTabletUi()) {
this
} else {
padding(bottom = MaterialTheme.padding.medium)
}
},
items = state.items,
)
},
)
}
Row(
modifier = modifier
.padding(horizontal = MaterialTheme.padding.small)
.padding(contentPadding),
content = {
if (isTabletUi()) {
Info(
modifier = Modifier
.weight(2f)
.padding(end = MaterialTheme.padding.extraLarge)
.fillMaxHeight(),
)
}
LazyColumn(
modifier = Modifier.weight(3f),
content = {
item {
Spacer(Modifier.height(MaterialTheme.padding.small))
}
item {
if (!isTabletUi()) {
Info()
}
}
items(
state.items.size,
itemContent = { index ->
StorageItem(
item = state.items[index],
isManga = isManga,
onDelete = onDelete,
)
Spacer(Modifier.height(MaterialTheme.padding.medium))
},
)
},
)
},
)
}
}
}
@Preview(showBackground = true)
@Composable
private fun StorageScreenContentPreview() {
val random = remember { Random(0) }
val categories = remember {
List(10) {
Category(
id = it.toLong(),
name = "Category $it",
0L,
0L,
false,
)
}
}
StorageScreenContent(
state = StorageScreenState.Success(
items = List(20) { index ->
StorageItem(
id = index.toLong(),
title = "Title $index",
size = index * 10000000L,
thumbnail = null,
entriesCount = 100 * index,
color = Color(
random.nextInt(255),
random.nextInt(255),
random.nextInt(255),
),
)
},
categories = categories,
selectedCategory = categories[0],
),
isManga = true,
contentPadding = PaddingValues(0.dp),
onCategorySelected = {},
onDelete = {},
)
}
@Preview(showBackground = true, device = Devices.DESKTOP)
@Composable
private fun StorageTabletUiScreenContentPreview() {
val random = remember { Random(0) }
val categories = remember {
List(10) {
Category(
id = it.toLong(),
name = "Category $it",
0L,
0L,
false,
)
}
}
StorageScreenContent(
state = StorageScreenState.Success(
items = List(20) { index ->
StorageItem(
id = index.toLong(),
title = "Title $index",
size = index * 10000000L,
thumbnail = null,
entriesCount = 100 * index,
color = Color(
random.nextInt(255),
random.nextInt(255),
random.nextInt(255),
),
)
},
categories = categories,
selectedCategory = categories[0],
),
isManga = true,
contentPadding = PaddingValues(0.dp),
onCategorySelected = {},
onDelete = {},
)
}

View file

@ -0,0 +1,16 @@
package eu.kanade.presentation.more.storage
import androidx.compose.runtime.Immutable
import tachiyomi.domain.category.model.Category
sealed class StorageScreenState {
@Immutable
object Loading : StorageScreenState()
@Immutable
data class Success(
val selectedCategory: Category,
val items: List<StorageItem>,
val categories: List<Category>,
) : StorageScreenState()
}

View file

@ -6,6 +6,7 @@ import com.hippo.unifile.UniFile
import eu.kanade.core.util.mapNotNullKeys
import eu.kanade.tachiyomi.animesource.AnimeSource
import eu.kanade.tachiyomi.extension.anime.AnimeExtensionManager
import eu.kanade.tachiyomi.util.size
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -175,6 +176,32 @@ class AnimeDownloadCache(
return 0
}
/**
* Returns the total size of downloaded episodes.
*/
fun getTotalDownloadSize(): Long {
renewCache()
return rootDownloadsDir.sourceDirs.values.sumOf { sourceDir ->
sourceDir.dir.size()
}
}
/**
* Returns the total size of downloaded chapters for an anime.
*
* @param anime the anime to check.
*/
fun getDownloadSize(anime: Anime): Long {
renewCache()
return rootDownloadsDir.sourceDirs[anime.source]?.animeDirs?.get(
provider.getAnimeDirName(
anime.title,
),
)?.dir?.size() ?: 0
}
/**
* Adds an episode that has just been download to this cache.
*

View file

@ -128,7 +128,13 @@ class AnimeDownloadManager(
* @param autoStart whether to start the downloader after enqueuing the episodes.
* @param alt whether to use the alternative downloader
*/
fun downloadEpisodes(anime: Anime, episodes: List<Episode>, autoStart: Boolean = true, alt: Boolean = false, video: Video? = null) {
fun downloadEpisodes(
anime: Anime,
episodes: List<Episode>,
autoStart: Boolean = true,
alt: Boolean = false,
video: Video? = null,
) {
downloader.queueEpisodes(anime, episodes, autoStart, alt, video)
}
@ -155,7 +161,8 @@ class AnimeDownloadManager(
* @return an observable containing the list of pages from the episode.
*/
fun buildVideo(source: AnimeSource, anime: Anime, episode: Episode): Observable<Video> {
val episodeDir = provider.findEpisodeDir(episode.name, episode.scanlator, anime.title, source)
val episodeDir =
provider.findEpisodeDir(episode.name, episode.scanlator, anime.title, source)
return Observable.fromCallable {
val files = episodeDir?.listFiles().orEmpty()
.filter { "video" in it.type.orEmpty() }
@ -165,7 +172,12 @@ class AnimeDownloadManager(
}
val file = files[0]
Video(file.uri.toString(), "download: " + file.uri.toString(), file.uri.toString(), file.uri).apply { status = Video.State.READY }
Video(
file.uri.toString(),
"download: " + file.uri.toString(),
file.uri.toString(),
file.uri,
).apply { status = Video.State.READY }
}
}
@ -185,7 +197,13 @@ class AnimeDownloadManager(
sourceId: Long,
skipCache: Boolean = false,
): Boolean {
return cache.isEpisodeDownloaded(episodeName, episodeScanlator, animeTitle, sourceId, skipCache)
return cache.isEpisodeDownloaded(
episodeName,
episodeScanlator,
animeTitle,
sourceId,
skipCache,
)
}
/**
@ -204,6 +222,22 @@ class AnimeDownloadManager(
return cache.getDownloadCount(anime)
}
/**
* Returns the size of downloaded episodes.
*/
fun getDownloadSize(): Long {
return cache.getTotalDownloadSize()
}
/**
* Returns the size of downloaded episodes for an anime.
*
* @param anime the anime to check.
*/
fun getDownloadSize(anime: Anime): Long {
return cache.getDownloadSize(anime)
}
fun cancelQueuedDownloads(downloads: List<AnimeDownload>) {
removeFromDownloadQueue(downloads.map { it.episode })
}
@ -223,10 +257,13 @@ class AnimeDownloadManager(
}
removeFromDownloadQueue(filteredEpisodes)
val (animeDir, episodeDirs) = provider.findEpisodeDirs(filteredEpisodes, anime, source)
episodeDirs.forEach { it.delete() }
cache.removeEpisodes(filteredEpisodes, anime)
val (animeDir, episodeDirs) = provider.findEpisodeDirs(
filteredEpisodes,
anime,
source,
)
episodeDirs.forEach { it.delete() }
cache.removeEpisodes(filteredEpisodes, anime)
// Delete anime directory if empty
if (animeDir?.listFiles()?.isEmpty() == true) {
@ -353,7 +390,8 @@ class AnimeDownloadManager(
private suspend fun getEpisodesToDelete(episodes: List<Episode>, anime: Anime): List<Episode> {
// Retrieve the categories that are set to exclude from being deleted on read
val categoriesToExclude = downloadPreferences.removeExcludeAnimeCategories().get().map(String::toLong)
val categoriesToExclude =
downloadPreferences.removeExcludeAnimeCategories().get().map(String::toLong)
val categoriesForAnime = getCategories.await(anime.id)
.map { it.id }
@ -381,7 +419,8 @@ class AnimeDownloadManager(
}
.onStart {
emitAll(
queueState.value.filter { download -> download.status == AnimeDownload.State.DOWNLOADING }.asFlow(),
queueState.value.filter { download -> download.status == AnimeDownload.State.DOWNLOADING }
.asFlow(),
)
}

View file

@ -8,6 +8,7 @@ import com.hippo.unifile.UniFile
import eu.kanade.core.util.mapNotNullKeys
import eu.kanade.tachiyomi.extension.manga.MangaExtensionManager
import eu.kanade.tachiyomi.source.MangaSource
import eu.kanade.tachiyomi.util.size
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -141,7 +142,12 @@ class MangaDownloadCache(
): Boolean {
if (skipCache) {
val source = sourceManager.getOrStub(sourceId)
return provider.findChapterDir(chapterName, chapterScanlator, mangaTitle, source) != null
return provider.findChapterDir(
chapterName,
chapterScanlator,
mangaTitle,
source,
) != null
}
renewCache()
@ -150,7 +156,8 @@ class MangaDownloadCache(
if (sourceDir != null) {
val mangaDir = sourceDir.mangaDirs[provider.getMangaDirName(mangaTitle)]
if (mangaDir != null) {
return provider.getValidChapterDirNames(chapterName, chapterScanlator).any { it in mangaDir.chapterDirs }
return provider.getValidChapterDirNames(chapterName, chapterScanlator)
.any { it in mangaDir.chapterDirs }
}
}
return false
@ -187,6 +194,32 @@ class MangaDownloadCache(
return 0
}
/**
* Returns the total size of downloaded chapters.
*/
fun getTotalDownloadSize(): Long {
renewCache()
return rootDownloadsDir.sourceDirs.values.sumOf { sourceDir ->
sourceDir.dir.size()
}
}
/**
* Returns the total size of downloaded chapters for a manga.
*
* @param manga the manga to check.
*/
fun getDownloadSize(manga: Manga): Long {
renewCache()
return rootDownloadsDir.sourceDirs[manga.source]?.mangaDirs?.get(
provider.getMangaDirName(
manga.title,
),
)?.dir?.size() ?: 0
}
/**
* Adds a chapter that has just been download to this cache.
*
@ -452,11 +485,13 @@ private class MangaDirectory(
)
private object UniFileAsStringSerializer : KSerializer<UniFile> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("UniFile", PrimitiveKind.STRING)
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("UniFile", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: UniFile) {
return encoder.encodeString(value.uri.toString())
}
override fun deserialize(decoder: Decoder): UniFile {
return UniFile.fromUri(Injekt.get<Application>(), Uri.parse(decoder.decodeString()))
}

View file

@ -201,6 +201,22 @@ class MangaDownloadManager(
return cache.getDownloadCount(manga)
}
/**
* Returns the size of downloaded chapters.
*/
fun getDownloadSize(): Long {
return cache.getTotalDownloadSize()
}
/**
* Returns the size of downloaded chapters for a manga.
*
* @param manga the manga to check.
*/
fun getDownloadSize(manga: Manga): Long {
return cache.getDownloadSize(manga)
}
fun cancelQueuedDownloads(downloads: List<MangaDownload>) {
removeFromDownloadQueue(downloads.map { it.chapter })
}

View file

@ -119,10 +119,11 @@ private fun FilterItem(filter: AnimeFilter<*>, onUpdate: () -> Unit) {
label = filter.name,
options = filter.values,
selectedIndex = filter.state,
) {
filter.state = it
onUpdate()
}
onSelect = {
filter.state = it
onUpdate()
},
)
}
is AnimeFilter.Sort -> {
CollapsibleBox(

View file

@ -119,10 +119,11 @@ private fun FilterItem(filter: Filter<*>, onUpdate: () -> Unit) {
label = filter.name,
options = filter.values,
selectedIndex = filter.state,
) {
filter.state = it
onUpdate()
}
onSelect = {
filter.state = it
onUpdate()
},
)
}
is Filter.Sort -> {
CollapsibleBox(

View file

@ -30,6 +30,7 @@ import eu.kanade.tachiyomi.ui.history.HistoriesTab
import eu.kanade.tachiyomi.ui.library.manga.MangaLibraryTab
import eu.kanade.tachiyomi.ui.setting.SettingsScreen
import eu.kanade.tachiyomi.ui.stats.StatsTab
import eu.kanade.tachiyomi.ui.storage.StorageTab
import eu.kanade.tachiyomi.ui.updates.UpdatesTab
import eu.kanade.tachiyomi.util.system.isInstalledFromFDroid
import kotlinx.coroutines.flow.MutableStateFlow
@ -78,6 +79,7 @@ object MoreTab : Tab() {
onClickDownloadQueue = { navigator.push(DownloadsTab()) },
onClickCategories = { navigator.push(CategoriesTab()) },
onClickStats = { navigator.push(StatsTab()) },
onClickStorage = { navigator.push(StorageTab()) },
onClickBackupAndRestore = { navigator.push(SettingsScreen.toBackupScreen()) },
onClickSettings = { navigator.push(SettingsScreen.toMainScreen()) },
onClickAbout = { navigator.push(SettingsScreen.toAboutScreen()) },

View file

@ -80,10 +80,10 @@ fun TracksCatalogSheet(
onTrackSelected = onAudioSelected,
)
when {
isEpisodeOnline == true && page == 0 -> QualityTracksPage()
page == 0 || page == 1 -> SubtitleTracksPage()
page == 2 -> AudioTracksPage()
when (page) {
0 -> if (isEpisodeOnline == true) QualityTracksPage() else SubtitleTracksPage()
1 -> if (isEpisodeOnline == true) SubtitleTracksPage() else AudioTracksPage()
2 -> if (isEpisodeOnline == true) AudioTracksPage()
}
}
}

View file

@ -0,0 +1,96 @@
package eu.kanade.tachiyomi.ui.storage
import androidx.compose.ui.graphics.Color
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.coroutineScope
import eu.kanade.core.util.fastDistinctBy
import eu.kanade.presentation.more.storage.StorageItem
import eu.kanade.presentation.more.storage.StorageScreenState
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import tachiyomi.core.util.lang.launchIO
import tachiyomi.domain.category.model.Category
import kotlin.random.Random
abstract class CommonStorageScreenModel<T>(
private val downloadCacheChanges: SharedFlow<Unit>,
private val downloadCacheIsInitializing: StateFlow<Boolean>,
private val libraries: Flow<List<T>>,
private val categories: Flow<List<Category>>,
private val getTotalDownloadSize: () -> Long,
private val getDownloadSize: T.() -> Long,
private val getDownloadCount: T.() -> Int,
private val getId: T.() -> Long,
private val getCategoryId: T.() -> Long,
private val getTitle: T.() -> String,
private val getThumbnail: T.() -> String?,
) : StateScreenModel<StorageScreenState>(StorageScreenState.Loading) {
private val selectedCategory = MutableStateFlow(AllCategory)
init {
coroutineScope.launchIO {
combine(
flow = downloadCacheChanges,
flow2 = downloadCacheIsInitializing,
flow3 = libraries,
flow4 = categories,
flow5 = selectedCategory,
transform = { _, _, libraries, categories, selectedCategory ->
val distinctLibraries = libraries.fastDistinctBy {
it.getId()
}.filter {
selectedCategory == AllCategory || it.getCategoryId() == selectedCategory.id
}
val size = getTotalDownloadSize()
val random = Random(size + distinctLibraries.size)
mutableState.update {
StorageScreenState.Success(
selectedCategory = selectedCategory,
categories = listOf(AllCategory, *categories.toTypedArray()),
items = distinctLibraries.map {
StorageItem(
id = it.getId(),
title = it.getTitle(),
size = it.getDownloadSize(),
thumbnail = it.getThumbnail(),
entriesCount = it.getDownloadCount(),
color = Color(
random.nextInt(255),
random.nextInt(255),
random.nextInt(255),
),
)
}.sortedByDescending { it.size },
)
}
},
).collectLatest {}
}
}
fun setSelectedCategory(category: Category) {
selectedCategory.update { category }
}
abstract fun deleteEntry(id: Long)
companion object {
/**
* A dummy category used to display all entries irrespective of the category.
*/
private val AllCategory = Category(
id = -1L,
name = "All",
order = 0L,
flags = 0L,
hidden = false,
)
}
}

View file

@ -0,0 +1,52 @@
package eu.kanade.tachiyomi.ui.storage
import androidx.compose.animation.graphics.res.animatedVectorResource
import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter
import androidx.compose.animation.graphics.vector.AnimatedImageVector
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import cafe.adriel.voyager.navigator.tab.LocalTabNavigator
import cafe.adriel.voyager.navigator.tab.TabOptions
import eu.kanade.presentation.components.TabbedScreen
import eu.kanade.presentation.util.Tab
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.storage.anime.animeStorageTab
import eu.kanade.tachiyomi.ui.storage.manga.mangaStorageTab
data class StorageTab(
private val isManga: Boolean = false,
) : Tab() {
override val options: TabOptions
@Composable
get() {
val isSelected = LocalTabNavigator.current.current.key == key
val image = AnimatedImageVector.animatedVectorResource(R.drawable.anim_updates_enter)
return TabOptions(
index = 8u,
title = stringResource(R.string.label_storage),
icon = rememberAnimatedVectorPainter(image, isSelected),
)
}
@Composable
override fun Content() {
val context = LocalContext.current
TabbedScreen(
titleRes = R.string.label_storage,
tabs = listOf(
animeStorageTab(),
mangaStorageTab(),
),
startIndex = 1.takeIf { isManga },
)
LaunchedEffect(Unit) {
(context as? MainActivity)?.ready = true
}
}
}

View file

@ -0,0 +1,43 @@
package eu.kanade.tachiyomi.ui.storage.anime
import cafe.adriel.voyager.core.model.coroutineScope
import eu.kanade.tachiyomi.data.download.anime.AnimeDownloadCache
import eu.kanade.tachiyomi.data.download.anime.AnimeDownloadManager
import eu.kanade.tachiyomi.ui.storage.CommonStorageScreenModel
import tachiyomi.core.util.lang.launchNonCancellable
import tachiyomi.domain.category.anime.interactor.GetVisibleAnimeCategories
import tachiyomi.domain.entries.anime.interactor.GetLibraryAnime
import tachiyomi.domain.library.anime.LibraryAnime
import tachiyomi.domain.source.anime.service.AnimeSourceManager
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class AnimeStorageScreenModel(
downloadCache: AnimeDownloadCache = Injekt.get(),
private val getLibraries: GetLibraryAnime = Injekt.get(),
getVisibleCategories: GetVisibleAnimeCategories = Injekt.get(),
private val downloadManager: AnimeDownloadManager = Injekt.get(),
private val sourceManager: AnimeSourceManager = Injekt.get(),
) : CommonStorageScreenModel<LibraryAnime>(
downloadCacheChanges = downloadCache.changes,
downloadCacheIsInitializing = downloadCache.isInitializing,
libraries = getLibraries.subscribe(),
categories = getVisibleCategories.subscribe(),
getTotalDownloadSize = { downloadManager.getDownloadSize() },
getDownloadSize = { downloadManager.getDownloadSize(anime) },
getDownloadCount = { downloadManager.getDownloadCount(anime) },
getId = { id },
getCategoryId = { category },
getTitle = { anime.title },
getThumbnail = { anime.thumbnailUrl },
) {
override fun deleteEntry(id: Long) {
coroutineScope.launchNonCancellable {
val anime = getLibraries.await().find {
it.id == id
}?.anime ?: return@launchNonCancellable
val source = sourceManager.get(anime.source) ?: return@launchNonCancellable
downloadManager.deleteAnime(anime, source)
}
}
}

View file

@ -0,0 +1,34 @@
package eu.kanade.tachiyomi.ui.storage.anime
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.components.TabContent
import eu.kanade.presentation.more.storage.StorageScreenContent
import eu.kanade.tachiyomi.R
@Composable
fun Screen.animeStorageTab(): TabContent {
val navigator = LocalNavigator.currentOrThrow
val screenModel = rememberScreenModel { AnimeStorageScreenModel() }
val state by screenModel.state.collectAsState()
return TabContent(
titleRes = R.string.label_anime,
content = { contentPadding, _ ->
StorageScreenContent(
state = state,
isManga = false,
contentPadding = contentPadding,
onCategorySelected = screenModel::setSelectedCategory,
onDelete = screenModel::deleteEntry,
)
},
navigateUp = navigator::pop,
)
}

View file

@ -0,0 +1,43 @@
package eu.kanade.tachiyomi.ui.storage.manga
import cafe.adriel.voyager.core.model.coroutineScope
import eu.kanade.tachiyomi.data.download.manga.MangaDownloadCache
import eu.kanade.tachiyomi.data.download.manga.MangaDownloadManager
import eu.kanade.tachiyomi.ui.storage.CommonStorageScreenModel
import tachiyomi.core.util.lang.launchNonCancellable
import tachiyomi.domain.category.manga.interactor.GetVisibleMangaCategories
import tachiyomi.domain.entries.manga.interactor.GetLibraryManga
import tachiyomi.domain.library.manga.LibraryManga
import tachiyomi.domain.source.manga.service.MangaSourceManager
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class MangaStorageScreenModel(
downloadCache: MangaDownloadCache = Injekt.get(),
private val getLibraries: GetLibraryManga = Injekt.get(),
getVisibleCategories: GetVisibleMangaCategories = Injekt.get(),
private val downloadManager: MangaDownloadManager = Injekt.get(),
private val sourceManager: MangaSourceManager = Injekt.get(),
) : CommonStorageScreenModel<LibraryManga>(
downloadCacheChanges = downloadCache.changes,
downloadCacheIsInitializing = downloadCache.isInitializing,
libraries = getLibraries.subscribe(),
categories = getVisibleCategories.subscribe(),
getTotalDownloadSize = { downloadManager.getDownloadSize() },
getDownloadSize = { downloadManager.getDownloadSize(manga) },
getDownloadCount = { downloadManager.getDownloadCount(manga) },
getId = { id },
getCategoryId = { category },
getTitle = { manga.title },
getThumbnail = { manga.thumbnailUrl },
) {
override fun deleteEntry(id: Long) {
coroutineScope.launchNonCancellable {
val manga = getLibraries.await().find {
it.id == id
}?.manga ?: return@launchNonCancellable
val source = sourceManager.get(manga.source) ?: return@launchNonCancellable
downloadManager.deleteManga(manga, source)
}
}
}

View file

@ -0,0 +1,34 @@
package eu.kanade.tachiyomi.ui.storage.manga
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.components.TabContent
import eu.kanade.presentation.more.storage.StorageScreenContent
import eu.kanade.tachiyomi.R
@Composable
fun Screen.mangaStorageTab(): TabContent {
val navigator = LocalNavigator.currentOrThrow
val screenModel = rememberScreenModel { MangaStorageScreenModel() }
val state by screenModel.state.collectAsState()
return TabContent(
titleRes = R.string.label_manga,
content = { contentPadding, _ ->
StorageScreenContent(
state = state,
isManga = true,
contentPadding = contentPadding,
onCategorySelected = screenModel::setSelectedCategory,
onDelete = screenModel::deleteEntry,
)
},
navigateUp = navigator::pop,
)
}

View file

@ -0,0 +1,38 @@
package eu.kanade.tachiyomi.util
import com.hippo.unifile.UniFile
/**
* Converts a long to a readable file size.
*/
fun Long.toSize(): String {
val kb = 1000
val mb = kb * kb
val gb = mb * kb
return when {
this >= gb -> "%.2f GB".format(this.toFloat() / gb)
this >= mb -> "%.2f MB".format(this.toFloat() / mb)
this >= kb -> "%.2f KB".format(this.toFloat() / kb)
else -> "$this B"
}
}
/**
* Returns the size of a file or directory.
*/
fun UniFile.size(): Long {
var totalSize = 0L
if (isDirectory) {
listFiles()?.forEach { file ->
totalSize += if (file.isDirectory) {
file.size()
} else {
val length = file.length()
if (length > 0) length else 0
}
}
} else {
totalSize = length()
}
return totalSize
}

View file

@ -3,6 +3,8 @@
<!-- Actions -->
<string name="action_hide">Hide</string>
<string name="label_all">All</string>
<string name="label_category">Category</string>
<string name="manga_categories">Manga Categories</string>
<string name="general_categories">Categories</string>
<string name="anime_categories">Anime Categories</string>
@ -177,6 +179,7 @@
<item quantity="one">%1$s episode</item>
<item quantity="other">%1$s episodes</item>
</plurals>
<string name="delete_confirmation">Are you sure you wish to delete \"%s\"?</string>
<string name="delete_downloads_for_anime">Delete downloaded episodes?</string>
<string name="snack_add_to_manga_library">Add manga to library?</string>
<string name="snack_add_to_anime_library">Add anime to library?</string>
@ -250,6 +253,7 @@
<item quantity="other">%d seconds</item>
</plurals>
<string name="no_next_episode">Next Episode not found!</string>
<string name="label_storage">Storage</string>
<string name="label_history">Manga</string>
<string name="label_anime_history">Anime</string>
<string name="label_updates">Manga</string>