mirror of
https://github.com/aniyomiorg/aniyomi.git
synced 2024-11-21 20:27:06 +03:00
Merge branch 'master' into MR
This commit is contained in:
commit
8f08a246a2
23 changed files with 1037 additions and 32 deletions
|
@ -60,7 +60,10 @@ fun TriStateItem(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = SettingsItemsPaddings.Horizontal, vertical = SettingsItemsPaddings.Vertical),
|
.padding(
|
||||||
|
horizontal = SettingsItemsPaddings.Horizontal,
|
||||||
|
vertical = SettingsItemsPaddings.Vertical,
|
||||||
|
),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
) {
|
) {
|
||||||
|
@ -91,15 +94,18 @@ fun TriStateItem(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SelectItem(
|
fun <T> SelectItem(
|
||||||
label: String,
|
label: String,
|
||||||
options: Array<out Any?>,
|
options: Array<T>,
|
||||||
selectedIndex: Int,
|
selectedIndex: Int,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
onSelect: (Int) -> Unit,
|
onSelect: (Int) -> Unit,
|
||||||
|
toString: (T) -> String = { it.toString() },
|
||||||
) {
|
) {
|
||||||
var expanded by remember { mutableStateOf(false) }
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
ExposedDropdownMenuBox(
|
ExposedDropdownMenuBox(
|
||||||
|
modifier = modifier,
|
||||||
expanded = expanded,
|
expanded = expanded,
|
||||||
onExpandedChange = { expanded = !expanded },
|
onExpandedChange = { expanded = !expanded },
|
||||||
) {
|
) {
|
||||||
|
@ -107,9 +113,12 @@ fun SelectItem(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.menuAnchor()
|
.menuAnchor()
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = SettingsItemsPaddings.Horizontal, vertical = SettingsItemsPaddings.Vertical),
|
.padding(
|
||||||
|
horizontal = SettingsItemsPaddings.Horizontal,
|
||||||
|
vertical = SettingsItemsPaddings.Vertical,
|
||||||
|
),
|
||||||
label = { Text(text = label) },
|
label = { Text(text = label) },
|
||||||
value = options[selectedIndex].toString(),
|
value = toString(options[selectedIndex]),
|
||||||
onValueChange = {},
|
onValueChange = {},
|
||||||
readOnly = true,
|
readOnly = true,
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
|
@ -126,9 +135,9 @@ fun SelectItem(
|
||||||
expanded = expanded,
|
expanded = expanded,
|
||||||
onDismissRequest = { expanded = false },
|
onDismissRequest = { expanded = false },
|
||||||
) {
|
) {
|
||||||
options.forEachIndexed { index, text ->
|
options.forEachIndexed { index, option ->
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(text.toString()) },
|
text = { Text(toString(option)) },
|
||||||
onClick = {
|
onClick = {
|
||||||
onSelect(index)
|
onSelect(index)
|
||||||
expanded = false
|
expanded = false
|
||||||
|
|
|
@ -19,6 +19,7 @@ import androidx.compose.material.icons.outlined.Label
|
||||||
import androidx.compose.material.icons.outlined.QueryStats
|
import androidx.compose.material.icons.outlined.QueryStats
|
||||||
import androidx.compose.material.icons.outlined.Settings
|
import androidx.compose.material.icons.outlined.Settings
|
||||||
import androidx.compose.material.icons.outlined.SettingsBackupRestore
|
import androidx.compose.material.icons.outlined.SettingsBackupRestore
|
||||||
|
import androidx.compose.material.icons.outlined.Storage
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
@ -50,6 +51,7 @@ fun MoreScreen(
|
||||||
onClickDownloadQueue: () -> Unit,
|
onClickDownloadQueue: () -> Unit,
|
||||||
onClickCategories: () -> Unit,
|
onClickCategories: () -> Unit,
|
||||||
onClickStats: () -> Unit,
|
onClickStats: () -> Unit,
|
||||||
|
onClickStorage: () -> Unit,
|
||||||
onClickBackupAndRestore: () -> Unit,
|
onClickBackupAndRestore: () -> Unit,
|
||||||
onClickSettings: () -> Unit,
|
onClickSettings: () -> Unit,
|
||||||
onClickAbout: () -> Unit,
|
onClickAbout: () -> Unit,
|
||||||
|
@ -165,6 +167,13 @@ fun MoreScreen(
|
||||||
onPreferenceClick = onClickStats,
|
onPreferenceClick = onClickStats,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
item {
|
||||||
|
TextPreferenceWidget(
|
||||||
|
title = stringResource(R.string.label_storage),
|
||||||
|
icon = Icons.Outlined.Storage,
|
||||||
|
onPreferenceClick = onClickStorage,
|
||||||
|
)
|
||||||
|
}
|
||||||
item {
|
item {
|
||||||
TextPreferenceWidget(
|
TextPreferenceWidget(
|
||||||
title = stringResource(R.string.label_backup),
|
title = stringResource(R.string.label_backup),
|
||||||
|
|
|
@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
|
@ -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 },
|
||||||
|
)
|
||||||
|
}
|
|
@ -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 = {
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
|
@ -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 = {},
|
||||||
|
)
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
|
@ -6,6 +6,7 @@ import com.hippo.unifile.UniFile
|
||||||
import eu.kanade.core.util.mapNotNullKeys
|
import eu.kanade.core.util.mapNotNullKeys
|
||||||
import eu.kanade.tachiyomi.animesource.AnimeSource
|
import eu.kanade.tachiyomi.animesource.AnimeSource
|
||||||
import eu.kanade.tachiyomi.extension.anime.AnimeExtensionManager
|
import eu.kanade.tachiyomi.extension.anime.AnimeExtensionManager
|
||||||
|
import eu.kanade.tachiyomi.util.size
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
@ -175,6 +176,32 @@ class AnimeDownloadCache(
|
||||||
return 0
|
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.
|
* Adds an episode that has just been download to this cache.
|
||||||
*
|
*
|
||||||
|
|
|
@ -128,7 +128,13 @@ class AnimeDownloadManager(
|
||||||
* @param autoStart whether to start the downloader after enqueuing the episodes.
|
* @param autoStart whether to start the downloader after enqueuing the episodes.
|
||||||
* @param alt whether to use the alternative downloader
|
* @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)
|
downloader.queueEpisodes(anime, episodes, autoStart, alt, video)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -155,7 +161,8 @@ class AnimeDownloadManager(
|
||||||
* @return an observable containing the list of pages from the episode.
|
* @return an observable containing the list of pages from the episode.
|
||||||
*/
|
*/
|
||||||
fun buildVideo(source: AnimeSource, anime: Anime, episode: Episode): Observable<Video> {
|
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 {
|
return Observable.fromCallable {
|
||||||
val files = episodeDir?.listFiles().orEmpty()
|
val files = episodeDir?.listFiles().orEmpty()
|
||||||
.filter { "video" in it.type.orEmpty() }
|
.filter { "video" in it.type.orEmpty() }
|
||||||
|
@ -165,7 +172,12 @@ class AnimeDownloadManager(
|
||||||
}
|
}
|
||||||
|
|
||||||
val file = files[0]
|
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,
|
sourceId: Long,
|
||||||
skipCache: Boolean = false,
|
skipCache: Boolean = false,
|
||||||
): Boolean {
|
): 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)
|
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>) {
|
fun cancelQueuedDownloads(downloads: List<AnimeDownload>) {
|
||||||
removeFromDownloadQueue(downloads.map { it.episode })
|
removeFromDownloadQueue(downloads.map { it.episode })
|
||||||
}
|
}
|
||||||
|
@ -223,10 +257,13 @@ class AnimeDownloadManager(
|
||||||
}
|
}
|
||||||
|
|
||||||
removeFromDownloadQueue(filteredEpisodes)
|
removeFromDownloadQueue(filteredEpisodes)
|
||||||
|
val (animeDir, episodeDirs) = provider.findEpisodeDirs(
|
||||||
val (animeDir, episodeDirs) = provider.findEpisodeDirs(filteredEpisodes, anime, source)
|
filteredEpisodes,
|
||||||
episodeDirs.forEach { it.delete() }
|
anime,
|
||||||
cache.removeEpisodes(filteredEpisodes, anime)
|
source,
|
||||||
|
)
|
||||||
|
episodeDirs.forEach { it.delete() }
|
||||||
|
cache.removeEpisodes(filteredEpisodes, anime)
|
||||||
|
|
||||||
// Delete anime directory if empty
|
// Delete anime directory if empty
|
||||||
if (animeDir?.listFiles()?.isEmpty() == true) {
|
if (animeDir?.listFiles()?.isEmpty() == true) {
|
||||||
|
@ -353,7 +390,8 @@ class AnimeDownloadManager(
|
||||||
|
|
||||||
private suspend fun getEpisodesToDelete(episodes: List<Episode>, anime: Anime): List<Episode> {
|
private suspend fun getEpisodesToDelete(episodes: List<Episode>, anime: Anime): List<Episode> {
|
||||||
// Retrieve the categories that are set to exclude from being deleted on read
|
// 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)
|
val categoriesForAnime = getCategories.await(anime.id)
|
||||||
.map { it.id }
|
.map { it.id }
|
||||||
|
@ -381,7 +419,8 @@ class AnimeDownloadManager(
|
||||||
}
|
}
|
||||||
.onStart {
|
.onStart {
|
||||||
emitAll(
|
emitAll(
|
||||||
queueState.value.filter { download -> download.status == AnimeDownload.State.DOWNLOADING }.asFlow(),
|
queueState.value.filter { download -> download.status == AnimeDownload.State.DOWNLOADING }
|
||||||
|
.asFlow(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -8,6 +8,7 @@ import com.hippo.unifile.UniFile
|
||||||
import eu.kanade.core.util.mapNotNullKeys
|
import eu.kanade.core.util.mapNotNullKeys
|
||||||
import eu.kanade.tachiyomi.extension.manga.MangaExtensionManager
|
import eu.kanade.tachiyomi.extension.manga.MangaExtensionManager
|
||||||
import eu.kanade.tachiyomi.source.MangaSource
|
import eu.kanade.tachiyomi.source.MangaSource
|
||||||
|
import eu.kanade.tachiyomi.util.size
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
@ -141,7 +142,12 @@ class MangaDownloadCache(
|
||||||
): Boolean {
|
): Boolean {
|
||||||
if (skipCache) {
|
if (skipCache) {
|
||||||
val source = sourceManager.getOrStub(sourceId)
|
val source = sourceManager.getOrStub(sourceId)
|
||||||
return provider.findChapterDir(chapterName, chapterScanlator, mangaTitle, source) != null
|
return provider.findChapterDir(
|
||||||
|
chapterName,
|
||||||
|
chapterScanlator,
|
||||||
|
mangaTitle,
|
||||||
|
source,
|
||||||
|
) != null
|
||||||
}
|
}
|
||||||
|
|
||||||
renewCache()
|
renewCache()
|
||||||
|
@ -150,7 +156,8 @@ class MangaDownloadCache(
|
||||||
if (sourceDir != null) {
|
if (sourceDir != null) {
|
||||||
val mangaDir = sourceDir.mangaDirs[provider.getMangaDirName(mangaTitle)]
|
val mangaDir = sourceDir.mangaDirs[provider.getMangaDirName(mangaTitle)]
|
||||||
if (mangaDir != null) {
|
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
|
return false
|
||||||
|
@ -187,6 +194,32 @@ class MangaDownloadCache(
|
||||||
return 0
|
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.
|
* Adds a chapter that has just been download to this cache.
|
||||||
*
|
*
|
||||||
|
@ -452,11 +485,13 @@ private class MangaDirectory(
|
||||||
)
|
)
|
||||||
|
|
||||||
private object UniFileAsStringSerializer : KSerializer<UniFile> {
|
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) {
|
override fun serialize(encoder: Encoder, value: UniFile) {
|
||||||
return encoder.encodeString(value.uri.toString())
|
return encoder.encodeString(value.uri.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun deserialize(decoder: Decoder): UniFile {
|
override fun deserialize(decoder: Decoder): UniFile {
|
||||||
return UniFile.fromUri(Injekt.get<Application>(), Uri.parse(decoder.decodeString()))
|
return UniFile.fromUri(Injekt.get<Application>(), Uri.parse(decoder.decodeString()))
|
||||||
}
|
}
|
||||||
|
|
|
@ -201,6 +201,22 @@ class MangaDownloadManager(
|
||||||
return cache.getDownloadCount(manga)
|
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>) {
|
fun cancelQueuedDownloads(downloads: List<MangaDownload>) {
|
||||||
removeFromDownloadQueue(downloads.map { it.chapter })
|
removeFromDownloadQueue(downloads.map { it.chapter })
|
||||||
}
|
}
|
||||||
|
|
|
@ -119,10 +119,11 @@ private fun FilterItem(filter: AnimeFilter<*>, onUpdate: () -> Unit) {
|
||||||
label = filter.name,
|
label = filter.name,
|
||||||
options = filter.values,
|
options = filter.values,
|
||||||
selectedIndex = filter.state,
|
selectedIndex = filter.state,
|
||||||
) {
|
onSelect = {
|
||||||
filter.state = it
|
filter.state = it
|
||||||
onUpdate()
|
onUpdate()
|
||||||
}
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
is AnimeFilter.Sort -> {
|
is AnimeFilter.Sort -> {
|
||||||
CollapsibleBox(
|
CollapsibleBox(
|
||||||
|
|
|
@ -119,10 +119,11 @@ private fun FilterItem(filter: Filter<*>, onUpdate: () -> Unit) {
|
||||||
label = filter.name,
|
label = filter.name,
|
||||||
options = filter.values,
|
options = filter.values,
|
||||||
selectedIndex = filter.state,
|
selectedIndex = filter.state,
|
||||||
) {
|
onSelect = {
|
||||||
filter.state = it
|
filter.state = it
|
||||||
onUpdate()
|
onUpdate()
|
||||||
}
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
is Filter.Sort -> {
|
is Filter.Sort -> {
|
||||||
CollapsibleBox(
|
CollapsibleBox(
|
||||||
|
|
|
@ -30,6 +30,7 @@ import eu.kanade.tachiyomi.ui.history.HistoriesTab
|
||||||
import eu.kanade.tachiyomi.ui.library.manga.MangaLibraryTab
|
import eu.kanade.tachiyomi.ui.library.manga.MangaLibraryTab
|
||||||
import eu.kanade.tachiyomi.ui.setting.SettingsScreen
|
import eu.kanade.tachiyomi.ui.setting.SettingsScreen
|
||||||
import eu.kanade.tachiyomi.ui.stats.StatsTab
|
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.ui.updates.UpdatesTab
|
||||||
import eu.kanade.tachiyomi.util.system.isInstalledFromFDroid
|
import eu.kanade.tachiyomi.util.system.isInstalledFromFDroid
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
@ -78,6 +79,7 @@ object MoreTab : Tab() {
|
||||||
onClickDownloadQueue = { navigator.push(DownloadsTab()) },
|
onClickDownloadQueue = { navigator.push(DownloadsTab()) },
|
||||||
onClickCategories = { navigator.push(CategoriesTab()) },
|
onClickCategories = { navigator.push(CategoriesTab()) },
|
||||||
onClickStats = { navigator.push(StatsTab()) },
|
onClickStats = { navigator.push(StatsTab()) },
|
||||||
|
onClickStorage = { navigator.push(StorageTab()) },
|
||||||
onClickBackupAndRestore = { navigator.push(SettingsScreen.toBackupScreen()) },
|
onClickBackupAndRestore = { navigator.push(SettingsScreen.toBackupScreen()) },
|
||||||
onClickSettings = { navigator.push(SettingsScreen.toMainScreen()) },
|
onClickSettings = { navigator.push(SettingsScreen.toMainScreen()) },
|
||||||
onClickAbout = { navigator.push(SettingsScreen.toAboutScreen()) },
|
onClickAbout = { navigator.push(SettingsScreen.toAboutScreen()) },
|
||||||
|
|
|
@ -80,10 +80,10 @@ fun TracksCatalogSheet(
|
||||||
onTrackSelected = onAudioSelected,
|
onTrackSelected = onAudioSelected,
|
||||||
)
|
)
|
||||||
|
|
||||||
when {
|
when (page) {
|
||||||
isEpisodeOnline == true && page == 0 -> QualityTracksPage()
|
0 -> if (isEpisodeOnline == true) QualityTracksPage() else SubtitleTracksPage()
|
||||||
page == 0 || page == 1 -> SubtitleTracksPage()
|
1 -> if (isEpisodeOnline == true) SubtitleTracksPage() else AudioTracksPage()
|
||||||
page == 2 -> AudioTracksPage()
|
2 -> if (isEpisodeOnline == true) AudioTracksPage()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
38
app/src/main/java/eu/kanade/tachiyomi/util/StorageUtil.kt
Normal file
38
app/src/main/java/eu/kanade/tachiyomi/util/StorageUtil.kt
Normal 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
|
||||||
|
}
|
|
@ -3,6 +3,8 @@
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<string name="action_hide">Hide</string>
|
<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="manga_categories">Manga Categories</string>
|
||||||
<string name="general_categories">Categories</string>
|
<string name="general_categories">Categories</string>
|
||||||
<string name="anime_categories">Anime Categories</string>
|
<string name="anime_categories">Anime Categories</string>
|
||||||
|
@ -177,6 +179,7 @@
|
||||||
<item quantity="one">%1$s episode</item>
|
<item quantity="one">%1$s episode</item>
|
||||||
<item quantity="other">%1$s episodes</item>
|
<item quantity="other">%1$s episodes</item>
|
||||||
</plurals>
|
</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="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_manga_library">Add manga to library?</string>
|
||||||
<string name="snack_add_to_anime_library">Add anime 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>
|
<item quantity="other">%d seconds</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<string name="no_next_episode">Next Episode not found!</string>
|
<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_history">Manga</string>
|
||||||
<string name="label_anime_history">Anime</string>
|
<string name="label_anime_history">Anime</string>
|
||||||
<string name="label_updates">Manga</string>
|
<string name="label_updates">Manga</string>
|
||||||
|
|
Loading…
Reference in a new issue