mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-01 21:24:52 +00:00
feat: refactor discover content handling and move to separate file
This commit is contained in:
parent
0b5d4d2816
commit
160e0a8c47
2 changed files with 396 additions and 356 deletions
|
|
@ -0,0 +1,389 @@
|
|||
package com.nuvio.app.features.search
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
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.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.KeyboardArrowDown
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.features.home.MetaPreview
|
||||
import com.nuvio.app.features.home.PosterShape
|
||||
import com.nuvio.app.features.home.components.HomeEmptyStateCard
|
||||
|
||||
internal fun LazyListScope.discoverContent(
|
||||
state: DiscoverUiState,
|
||||
onTypeSelected: (String) -> Unit,
|
||||
onCatalogSelected: (String) -> Unit,
|
||||
onGenreSelected: (String?) -> Unit,
|
||||
onPosterClick: ((MetaPreview) -> Unit)? = null,
|
||||
) {
|
||||
item {
|
||||
DiscoverSectionHeader(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
}
|
||||
item {
|
||||
DiscoverFilterRow(
|
||||
state = state,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
onTypeSelected = onTypeSelected,
|
||||
onCatalogSelected = onCatalogSelected,
|
||||
onGenreSelected = onGenreSelected,
|
||||
)
|
||||
}
|
||||
state.selectedCatalog?.let { selectedCatalog ->
|
||||
item {
|
||||
Text(
|
||||
text = "${selectedCatalog.addonName} • ${selectedCatalog.type.displayTypeLabel()}",
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
state.isLoading && state.items.isEmpty() -> {
|
||||
items(2) {
|
||||
DiscoverSkeletonRow(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
state.items.isEmpty() -> {
|
||||
item {
|
||||
DiscoverEmptyStateCard(
|
||||
reason = state.emptyStateReason,
|
||||
errorMessage = state.errorMessage,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
items(state.items.chunked(3)) { rowItems ->
|
||||
DiscoverGridRow(
|
||||
items = rowItems,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
onPosterClick = onPosterClick,
|
||||
)
|
||||
}
|
||||
if (state.isLoading) {
|
||||
item {
|
||||
CatalogLoadingFooter(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DiscoverSectionHeader(modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text = "Discover",
|
||||
modifier = modifier,
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DiscoverFilterRow(
|
||||
state: DiscoverUiState,
|
||||
onTypeSelected: (String) -> Unit,
|
||||
onCatalogSelected: (String) -> Unit,
|
||||
onGenreSelected: (String?) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
DiscoverDropdownChip(
|
||||
label = state.selectedType?.displayTypeLabel() ?: "Type",
|
||||
options = state.typeOptions.map { DiscoverOptionItem(key = it, label = it.displayTypeLabel()) },
|
||||
enabled = state.typeOptions.isNotEmpty(),
|
||||
onSelected = { onTypeSelected(it.key) },
|
||||
)
|
||||
DiscoverDropdownChip(
|
||||
label = state.selectedCatalog?.catalogName ?: "Catalog",
|
||||
options = state.catalogOptions.map { option -> DiscoverOptionItem(key = option.key, label = option.catalogName) },
|
||||
enabled = state.catalogOptions.isNotEmpty(),
|
||||
onSelected = { onCatalogSelected(it.key) },
|
||||
)
|
||||
|
||||
val selectedCatalog = state.selectedCatalog
|
||||
val genreOptions = buildList {
|
||||
if (selectedCatalog?.genreRequired != true) {
|
||||
add(DiscoverOptionItem(key = "", label = "All Genres"))
|
||||
}
|
||||
addAll(state.genreOptions.map { genre -> DiscoverOptionItem(key = genre, label = genre) })
|
||||
}
|
||||
DiscoverDropdownChip(
|
||||
label = state.selectedGenre ?: "All Genres",
|
||||
options = genreOptions,
|
||||
enabled = genreOptions.size > 1 || selectedCatalog?.genreRequired == true,
|
||||
onSelected = { option ->
|
||||
onGenreSelected(option.key.ifBlank { null })
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DiscoverDropdownChip(
|
||||
label: String,
|
||||
options: List<DiscoverOptionItem>,
|
||||
enabled: Boolean,
|
||||
onSelected: (DiscoverOptionItem) -> Unit,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
Box {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.then(
|
||||
if (enabled) {
|
||||
Modifier.clickable { expanded = true }
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
.padding(horizontal = 18.dp, vertical = 14.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.KeyboardArrowDown,
|
||||
contentDescription = null,
|
||||
tint = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
) {
|
||||
options.forEach { option ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(option.label) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
onSelected(option)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DiscoverGridRow(
|
||||
items: List<MetaPreview>,
|
||||
modifier: Modifier = Modifier,
|
||||
onPosterClick: ((MetaPreview) -> Unit)? = null,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
items.forEach { item ->
|
||||
DiscoverPosterTile(
|
||||
item = item,
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = onPosterClick?.let { { it(item) } },
|
||||
)
|
||||
}
|
||||
repeat(3 - items.size) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DiscoverPosterTile(
|
||||
item: MetaPreview,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(item.posterShape.discoverAspectRatio())
|
||||
.clip(RoundedCornerShape(22.dp))
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
) {
|
||||
if (item.poster != null) {
|
||||
AsyncImage(
|
||||
model = item.poster,
|
||||
contentDescription = item.name,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = item.name,
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val detail = item.releaseInfo ?: item.imdbRating?.let { "IMDb $it" }
|
||||
if (detail != null) {
|
||||
Text(
|
||||
text = detail,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DiscoverSkeletonRow(modifier: Modifier = Modifier) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
repeat(3) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.aspectRatio(0.68f)
|
||||
.clip(RoundedCornerShape(22.dp))
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CatalogLoadingFooter(modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(22.dp),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DiscoverEmptyStateCard(
|
||||
reason: DiscoverEmptyStateReason?,
|
||||
errorMessage: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val title: String
|
||||
val message: String
|
||||
|
||||
when (reason) {
|
||||
DiscoverEmptyStateReason.NoActiveAddons -> {
|
||||
title = "No active addons"
|
||||
message = "Install and validate at least one addon before browsing discover catalogs."
|
||||
}
|
||||
|
||||
DiscoverEmptyStateReason.NoDiscoverCatalogs -> {
|
||||
title = "No discover catalogs"
|
||||
message = "Installed addons do not expose board-compatible catalogs for discover."
|
||||
}
|
||||
|
||||
DiscoverEmptyStateReason.RequestFailed -> {
|
||||
title = "Could not load discover"
|
||||
message = errorMessage ?: "The selected catalog failed to return discover items."
|
||||
}
|
||||
|
||||
DiscoverEmptyStateReason.NoResults, null -> {
|
||||
title = "No titles found"
|
||||
message = "The selected catalog and filters did not return any items."
|
||||
}
|
||||
}
|
||||
|
||||
HomeEmptyStateCard(
|
||||
modifier = modifier,
|
||||
title = title,
|
||||
message = message,
|
||||
)
|
||||
}
|
||||
|
||||
private data class DiscoverOptionItem(
|
||||
val key: String,
|
||||
val label: String,
|
||||
)
|
||||
|
||||
private fun String.displayTypeLabel(): String =
|
||||
when (lowercase()) {
|
||||
"movie" -> "Movies"
|
||||
"series" -> "Series"
|
||||
"anime" -> "Anime"
|
||||
"channel" -> "Channels"
|
||||
"tv" -> "TV"
|
||||
else -> replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
|
||||
}
|
||||
|
||||
private fun PosterShape.discoverAspectRatio(): Float =
|
||||
when (this) {
|
||||
PosterShape.Poster -> 0.68f
|
||||
PosterShape.Square -> 1f
|
||||
PosterShape.Landscape -> 1.2f
|
||||
}
|
||||
|
|
@ -1,32 +1,19 @@
|
|||
package com.nuvio.app.features.search
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.KeyboardArrowDown
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
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
|
||||
|
|
@ -36,23 +23,15 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.ui.NuvioInputField
|
||||
import com.nuvio.app.core.ui.NuvioScreenHeader
|
||||
import com.nuvio.app.core.ui.nuvioPlatformExtraBottomPadding
|
||||
import com.nuvio.app.features.addons.AddonRepository
|
||||
import com.nuvio.app.features.home.MetaPreview
|
||||
import com.nuvio.app.features.home.PosterShape
|
||||
import com.nuvio.app.features.home.components.HomeCatalogRowSection
|
||||
import com.nuvio.app.features.home.components.HomeEmptyStateCard
|
||||
import com.nuvio.app.features.home.components.HomeSkeletonRow
|
||||
|
|
@ -147,66 +126,13 @@ fun SearchScreen(
|
|||
}
|
||||
|
||||
if (query.isBlank()) {
|
||||
item {
|
||||
DiscoverSectionHeader(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
}
|
||||
item {
|
||||
DiscoverFilterRow(
|
||||
state = discoverUiState,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
onTypeSelected = SearchRepository::selectDiscoverType,
|
||||
onCatalogSelected = SearchRepository::selectDiscoverCatalog,
|
||||
onGenreSelected = SearchRepository::selectDiscoverGenre,
|
||||
)
|
||||
}
|
||||
discoverUiState.selectedCatalog?.let { selectedCatalog ->
|
||||
item {
|
||||
Text(
|
||||
text = "${selectedCatalog.addonName} • ${selectedCatalog.type.displayTypeLabel()}",
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
discoverUiState.isLoading && discoverUiState.items.isEmpty() -> {
|
||||
items(2) {
|
||||
DiscoverSkeletonRow(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
discoverUiState.items.isEmpty() -> {
|
||||
item {
|
||||
DiscoverEmptyStateCard(
|
||||
reason = discoverUiState.emptyStateReason,
|
||||
errorMessage = discoverUiState.errorMessage,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
items(discoverUiState.items.chunked(3)) { rowItems ->
|
||||
DiscoverGridRow(
|
||||
items = rowItems,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
onPosterClick = onPosterClick,
|
||||
)
|
||||
}
|
||||
if (discoverUiState.isLoading) {
|
||||
item {
|
||||
CatalogLoadingFooter(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
discoverContent(
|
||||
state = discoverUiState,
|
||||
onTypeSelected = SearchRepository::selectDiscoverType,
|
||||
onCatalogSelected = SearchRepository::selectDiscoverCatalog,
|
||||
onGenreSelected = SearchRepository::selectDiscoverGenre,
|
||||
onPosterClick = onPosterClick,
|
||||
)
|
||||
} else {
|
||||
when {
|
||||
uiState.isLoading && uiState.sections.isEmpty() -> {
|
||||
|
|
@ -260,259 +186,6 @@ fun SearchScreen(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DiscoverSectionHeader(modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text = "Discover",
|
||||
modifier = modifier,
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DiscoverFilterRow(
|
||||
state: DiscoverUiState,
|
||||
onTypeSelected: (String) -> Unit,
|
||||
onCatalogSelected: (String) -> Unit,
|
||||
onGenreSelected: (String?) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
DiscoverDropdownChip(
|
||||
label = state.selectedType?.displayTypeLabel() ?: "Type",
|
||||
options = state.typeOptions.map { DiscoverOptionItem(key = it, label = it.displayTypeLabel()) },
|
||||
enabled = state.typeOptions.isNotEmpty(),
|
||||
onSelected = { onTypeSelected(it.key) },
|
||||
)
|
||||
DiscoverDropdownChip(
|
||||
label = state.selectedCatalog?.catalogName ?: "Catalog",
|
||||
options = state.catalogOptions.map { option -> DiscoverOptionItem(key = option.key, label = option.catalogName) },
|
||||
enabled = state.catalogOptions.isNotEmpty(),
|
||||
onSelected = { onCatalogSelected(it.key) },
|
||||
)
|
||||
|
||||
val selectedCatalog = state.selectedCatalog
|
||||
val genreOptions = buildList {
|
||||
if (selectedCatalog?.genreRequired != true) {
|
||||
add(DiscoverOptionItem(key = "", label = "All Genres"))
|
||||
}
|
||||
addAll(state.genreOptions.map { genre -> DiscoverOptionItem(key = genre, label = genre) })
|
||||
}
|
||||
DiscoverDropdownChip(
|
||||
label = state.selectedGenre ?: "All Genres",
|
||||
options = genreOptions,
|
||||
enabled = genreOptions.size > 1 || selectedCatalog?.genreRequired == true,
|
||||
onSelected = { option ->
|
||||
onGenreSelected(option.key.ifBlank { null })
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DiscoverDropdownChip(
|
||||
label: String,
|
||||
options: List<DiscoverOptionItem>,
|
||||
enabled: Boolean,
|
||||
onSelected: (DiscoverOptionItem) -> Unit,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
Box {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.then(
|
||||
if (enabled) {
|
||||
Modifier.clickable { expanded = true }
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
.padding(horizontal = 18.dp, vertical = 14.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
androidx.compose.material3.Icon(
|
||||
imageVector = Icons.Rounded.KeyboardArrowDown,
|
||||
contentDescription = null,
|
||||
tint = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
) {
|
||||
options.forEach { option ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(option.label) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
onSelected(option)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DiscoverGridRow(
|
||||
items: List<MetaPreview>,
|
||||
modifier: Modifier = Modifier,
|
||||
onPosterClick: ((MetaPreview) -> Unit)? = null,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
items.forEach { item ->
|
||||
DiscoverPosterTile(
|
||||
item = item,
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = onPosterClick?.let { { it(item) } },
|
||||
)
|
||||
}
|
||||
repeat(3 - items.size) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DiscoverPosterTile(
|
||||
item: MetaPreview,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(item.posterShape.discoverAspectRatio())
|
||||
.clip(RoundedCornerShape(22.dp))
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
) {
|
||||
if (item.poster != null) {
|
||||
AsyncImage(
|
||||
model = item.poster,
|
||||
contentDescription = item.name,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = item.name,
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val detail = item.releaseInfo ?: item.imdbRating?.let { "IMDb $it" }
|
||||
if (detail != null) {
|
||||
Text(
|
||||
text = detail,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DiscoverSkeletonRow(modifier: Modifier = Modifier) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
repeat(3) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.aspectRatio(0.68f)
|
||||
.clip(RoundedCornerShape(22.dp))
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CatalogLoadingFooter(modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
androidx.compose.material3.CircularProgressIndicator(
|
||||
modifier = Modifier.size(22.dp),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DiscoverEmptyStateCard(
|
||||
reason: DiscoverEmptyStateReason?,
|
||||
errorMessage: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val title: String
|
||||
val message: String
|
||||
|
||||
when (reason) {
|
||||
DiscoverEmptyStateReason.NoActiveAddons -> {
|
||||
title = "No active addons"
|
||||
message = "Install and validate at least one addon before browsing discover catalogs."
|
||||
}
|
||||
|
||||
DiscoverEmptyStateReason.NoDiscoverCatalogs -> {
|
||||
title = "No discover catalogs"
|
||||
message = "Installed addons do not expose board-compatible catalogs for discover."
|
||||
}
|
||||
|
||||
DiscoverEmptyStateReason.RequestFailed -> {
|
||||
title = "Could not load discover"
|
||||
message = errorMessage ?: "The selected catalog failed to return discover items."
|
||||
}
|
||||
|
||||
DiscoverEmptyStateReason.NoResults, null -> {
|
||||
title = "No titles found"
|
||||
message = "The selected catalog and filters did not return any items."
|
||||
}
|
||||
}
|
||||
|
||||
HomeEmptyStateCard(
|
||||
modifier = modifier,
|
||||
title = title,
|
||||
message = message,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchEmptyStateCard(
|
||||
reason: SearchEmptyStateReason?,
|
||||
|
|
@ -550,25 +223,3 @@ private fun SearchEmptyStateCard(
|
|||
message = message,
|
||||
)
|
||||
}
|
||||
|
||||
private data class DiscoverOptionItem(
|
||||
val key: String,
|
||||
val label: String,
|
||||
)
|
||||
|
||||
private fun String.displayTypeLabel(): String =
|
||||
when (lowercase()) {
|
||||
"movie" -> "Movies"
|
||||
"series" -> "Series"
|
||||
"anime" -> "Anime"
|
||||
"channel" -> "Channels"
|
||||
"tv" -> "TV"
|
||||
else -> replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
|
||||
}
|
||||
|
||||
private fun PosterShape.discoverAspectRatio(): Float =
|
||||
when (this) {
|
||||
PosterShape.Poster -> 0.68f
|
||||
PosterShape.Square -> 1f
|
||||
PosterShape.Landscape -> 1.2f
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue