mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-26 22:42:17 +00:00
discover screen init
This commit is contained in:
parent
242aee0fa5
commit
0b5d4d2816
14 changed files with 1374 additions and 107 deletions
|
|
@ -32,8 +32,11 @@ import coil3.compose.setSingletonImageLoaderFactory
|
|||
import coil3.request.crossfade
|
||||
import com.nuvio.app.core.ui.NuvioTheme
|
||||
import com.nuvio.app.features.addons.AddonsScreen
|
||||
import com.nuvio.app.features.catalog.CatalogRepository
|
||||
import com.nuvio.app.features.catalog.CatalogScreen
|
||||
import com.nuvio.app.features.details.MetaDetailsRepository
|
||||
import com.nuvio.app.features.details.MetaDetailsScreen
|
||||
import com.nuvio.app.features.home.HomeCatalogSection
|
||||
import com.nuvio.app.features.home.HomeScreen
|
||||
import com.nuvio.app.features.home.MetaPreview
|
||||
import com.nuvio.app.features.search.SearchScreen
|
||||
|
|
@ -61,6 +64,17 @@ data class StreamRoute(
|
|||
val episodeThumbnail: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CatalogRoute(
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
val manifestUrl: String,
|
||||
val type: String,
|
||||
val catalogId: String,
|
||||
val supportsPagination: Boolean = false,
|
||||
val genre: String? = null,
|
||||
)
|
||||
|
||||
enum class AppScreenTab {
|
||||
Home,
|
||||
Search,
|
||||
|
|
@ -71,11 +85,13 @@ enum class AppScreenTab {
|
|||
fun AppScreen(
|
||||
tab: AppScreenTab,
|
||||
modifier: Modifier = Modifier,
|
||||
onCatalogClick: ((HomeCatalogSection) -> Unit)? = null,
|
||||
onPosterClick: ((MetaPreview) -> Unit)? = null,
|
||||
) {
|
||||
when (tab) {
|
||||
AppScreenTab.Home -> HomeScreen(
|
||||
modifier = modifier,
|
||||
onCatalogClick = onCatalogClick,
|
||||
onPosterClick = onPosterClick,
|
||||
)
|
||||
AppScreenTab.Search -> SearchScreen(
|
||||
|
|
@ -117,6 +133,19 @@ fun App() {
|
|||
)
|
||||
}
|
||||
|
||||
val onCatalogClick: (HomeCatalogSection) -> Unit = { section ->
|
||||
navController.navigate(
|
||||
CatalogRoute(
|
||||
title = section.title,
|
||||
subtitle = section.subtitle,
|
||||
manifestUrl = section.manifestUrl,
|
||||
type = section.type,
|
||||
catalogId = section.catalogId,
|
||||
supportsPagination = section.supportsPagination,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
|
|
@ -155,6 +184,7 @@ fun App() {
|
|||
AppScreen(
|
||||
tab = selectedTab,
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
onCatalogClick = onCatalogClick,
|
||||
onPosterClick = { meta ->
|
||||
navController.navigate(DetailRoute(type = meta.type, id = meta.id))
|
||||
},
|
||||
|
|
@ -202,6 +232,26 @@ fun App() {
|
|||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
composable<CatalogRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<CatalogRoute>()
|
||||
CatalogScreen(
|
||||
title = route.title,
|
||||
subtitle = route.subtitle,
|
||||
manifestUrl = route.manifestUrl,
|
||||
type = route.type,
|
||||
catalogId = route.catalogId,
|
||||
supportsPagination = route.supportsPagination,
|
||||
genre = route.genre,
|
||||
onBack = {
|
||||
CatalogRepository.clear()
|
||||
navController.popBackStack()
|
||||
},
|
||||
onPosterClick = { meta ->
|
||||
navController.navigate(DetailRoute(type = meta.type, id = meta.id))
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,6 +71,8 @@ internal object AddonManifestParser {
|
|||
AddonExtraProperty(
|
||||
name = name,
|
||||
isRequired = extraElement.jsonObject.boolean("isRequired"),
|
||||
options = extraElement.jsonObject.stringList("options"),
|
||||
optionsLimit = extraElement.jsonObject.int("optionsLimit"),
|
||||
)
|
||||
}
|
||||
},
|
||||
|
|
@ -104,4 +106,7 @@ internal object AddonManifestParser {
|
|||
|
||||
private fun JsonObject.boolean(name: String): Boolean =
|
||||
this[name]?.jsonPrimitive?.booleanOrNull == true
|
||||
|
||||
private fun JsonObject.int(name: String): Int? =
|
||||
this[name]?.jsonPrimitive?.contentOrNull?.toIntOrNull()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ data class AddonCatalog(
|
|||
data class AddonExtraProperty(
|
||||
val name: String,
|
||||
val isRequired: Boolean = false,
|
||||
val options: List<String> = emptyList(),
|
||||
val optionsLimit: Int? = null,
|
||||
)
|
||||
|
||||
data class AddonBehaviorHints(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,113 @@
|
|||
package com.nuvio.app.features.catalog
|
||||
|
||||
import com.nuvio.app.features.addons.AddonCatalog
|
||||
import com.nuvio.app.features.addons.httpGetText
|
||||
import com.nuvio.app.features.home.HomeCatalogParser
|
||||
import com.nuvio.app.features.home.MetaPreview
|
||||
|
||||
const val CATALOG_PAGE_SIZE = 100
|
||||
|
||||
data class CatalogPage(
|
||||
val items: List<MetaPreview>,
|
||||
val nextSkip: Int?,
|
||||
)
|
||||
|
||||
suspend fun fetchCatalogPage(
|
||||
manifestUrl: String,
|
||||
type: String,
|
||||
catalogId: String,
|
||||
genre: String? = null,
|
||||
search: String? = null,
|
||||
skip: Int? = null,
|
||||
): CatalogPage {
|
||||
val payload = httpGetText(
|
||||
buildCatalogUrl(
|
||||
manifestUrl = manifestUrl,
|
||||
type = type,
|
||||
catalogId = catalogId,
|
||||
genre = genre,
|
||||
search = search,
|
||||
skip = skip,
|
||||
),
|
||||
)
|
||||
val items = HomeCatalogParser.parseCatalog(payload)
|
||||
val nextSkip = if (items.size >= CATALOG_PAGE_SIZE) {
|
||||
(skip ?: 0) + CATALOG_PAGE_SIZE
|
||||
} else {
|
||||
null
|
||||
}
|
||||
return CatalogPage(
|
||||
items = items,
|
||||
nextSkip = nextSkip,
|
||||
)
|
||||
}
|
||||
|
||||
fun AddonCatalog.supportsPagination(): Boolean =
|
||||
extra.any { property -> property.name == "skip" }
|
||||
|
||||
fun mergeCatalogItems(
|
||||
existing: List<MetaPreview>,
|
||||
incoming: List<MetaPreview>,
|
||||
): List<MetaPreview> {
|
||||
if (incoming.isEmpty()) return existing
|
||||
val seen = existing.mapTo(mutableSetOf()) { item -> "${item.type}:${item.id}" }
|
||||
return buildList(existing.size + incoming.size) {
|
||||
addAll(existing)
|
||||
incoming.forEach { item ->
|
||||
val key = "${item.type}:${item.id}"
|
||||
if (seen.add(key)) {
|
||||
add(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildCatalogUrl(
|
||||
manifestUrl: String,
|
||||
type: String,
|
||||
catalogId: String,
|
||||
genre: String?,
|
||||
search: String?,
|
||||
skip: Int?,
|
||||
): String {
|
||||
val baseUrl = manifestUrl
|
||||
.substringBefore("?")
|
||||
.removeSuffix("/manifest.json")
|
||||
|
||||
val extraParts = buildList {
|
||||
if (!search.isNullOrBlank()) add("search=${search.encodeCatalogExtra()}")
|
||||
if (!genre.isNullOrBlank()) add("genre=${genre.encodeCatalogExtra()}")
|
||||
if (skip != null && skip > 0) add("skip=$skip")
|
||||
}
|
||||
|
||||
return if (extraParts.isEmpty()) {
|
||||
"$baseUrl/catalog/$type/$catalogId.json"
|
||||
} else {
|
||||
"$baseUrl/catalog/$type/$catalogId/${extraParts.joinToString(separator = "&")}.json"
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.encodeCatalogExtra(): String =
|
||||
buildString {
|
||||
encodeToByteArray().forEach { byte ->
|
||||
val value = byte.toInt() and 0xFF
|
||||
val char = value.toChar()
|
||||
if (
|
||||
char in 'a'..'z' ||
|
||||
char in 'A'..'Z' ||
|
||||
char in '0'..'9' ||
|
||||
char == '-' ||
|
||||
char == '_' ||
|
||||
char == '.' ||
|
||||
char == '~'
|
||||
) {
|
||||
append(char)
|
||||
} else {
|
||||
append('%')
|
||||
append(HEX[value shr 4])
|
||||
append(HEX[value and 0x0F])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val HEX = "0123456789ABCDEF"
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package com.nuvio.app.features.catalog
|
||||
|
||||
import com.nuvio.app.features.home.MetaPreview
|
||||
|
||||
data class CatalogUiState(
|
||||
val items: List<MetaPreview> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val nextSkip: Int? = null,
|
||||
val errorMessage: String? = null,
|
||||
) {
|
||||
val canLoadMore: Boolean
|
||||
get() = nextSkip != null
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
package com.nuvio.app.features.catalog
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
object CatalogRepository {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val _uiState = MutableStateFlow(CatalogUiState())
|
||||
val uiState: StateFlow<CatalogUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var activeJob: Job? = null
|
||||
private var activeRequest: CatalogRequest? = null
|
||||
|
||||
fun load(
|
||||
manifestUrl: String,
|
||||
type: String,
|
||||
catalogId: String,
|
||||
genre: String? = null,
|
||||
supportsPagination: Boolean = false,
|
||||
force: Boolean = false,
|
||||
) {
|
||||
val request = CatalogRequest(
|
||||
manifestUrl = manifestUrl,
|
||||
type = type,
|
||||
catalogId = catalogId,
|
||||
genre = genre,
|
||||
supportsPagination = supportsPagination,
|
||||
)
|
||||
if (!force && activeRequest == request && (_uiState.value.items.isNotEmpty() || _uiState.value.isLoading)) {
|
||||
return
|
||||
}
|
||||
activeRequest = request
|
||||
fetchPage(request = request, reset = true)
|
||||
}
|
||||
|
||||
fun loadMore() {
|
||||
val request = activeRequest ?: return
|
||||
val current = _uiState.value
|
||||
if (current.isLoading || current.nextSkip == null) return
|
||||
fetchPage(request = request, reset = false)
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
activeJob?.cancel()
|
||||
activeRequest = null
|
||||
_uiState.value = CatalogUiState()
|
||||
}
|
||||
|
||||
private fun fetchPage(
|
||||
request: CatalogRequest,
|
||||
reset: Boolean,
|
||||
) {
|
||||
activeJob?.cancel()
|
||||
val current = _uiState.value
|
||||
val requestedSkip = if (reset) 0 else current.nextSkip ?: return
|
||||
|
||||
_uiState.value = current.copy(
|
||||
items = if (reset) emptyList() else current.items,
|
||||
isLoading = true,
|
||||
nextSkip = if (reset) null else current.nextSkip,
|
||||
errorMessage = null,
|
||||
)
|
||||
|
||||
activeJob = scope.launch {
|
||||
runCatching {
|
||||
fetchCatalogPage(
|
||||
manifestUrl = request.manifestUrl,
|
||||
type = request.type,
|
||||
catalogId = request.catalogId,
|
||||
genre = request.genre,
|
||||
skip = requestedSkip.takeIf { it > 0 },
|
||||
)
|
||||
}.fold(
|
||||
onSuccess = { page ->
|
||||
if (activeRequest != request) return@fold
|
||||
|
||||
val mergedItems = if (reset) {
|
||||
page.items
|
||||
} else {
|
||||
mergeCatalogItems(_uiState.value.items, page.items)
|
||||
}
|
||||
_uiState.value = CatalogUiState(
|
||||
items = mergedItems,
|
||||
isLoading = false,
|
||||
nextSkip = if (request.supportsPagination) page.nextSkip else null,
|
||||
errorMessage = null,
|
||||
)
|
||||
},
|
||||
onFailure = { error ->
|
||||
if (activeRequest != request) return@fold
|
||||
|
||||
_uiState.value = current.copy(
|
||||
items = if (reset) emptyList() else current.items,
|
||||
isLoading = false,
|
||||
nextSkip = null,
|
||||
errorMessage = error.message ?: "Unable to load catalog items.",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class CatalogRequest(
|
||||
val manifestUrl: String,
|
||||
val type: String,
|
||||
val catalogId: String,
|
||||
val genre: String?,
|
||||
val supportsPagination: Boolean,
|
||||
)
|
||||
|
|
@ -0,0 +1,297 @@
|
|||
package com.nuvio.app.features.catalog
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
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.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.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
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.nuvioPlatformExtraBottomPadding
|
||||
import com.nuvio.app.features.home.MetaPreview
|
||||
import com.nuvio.app.features.home.PosterShape
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
@Composable
|
||||
fun CatalogScreen(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
manifestUrl: String,
|
||||
type: String,
|
||||
catalogId: String,
|
||||
supportsPagination: Boolean,
|
||||
genre: String? = null,
|
||||
onBack: () -> Unit,
|
||||
onPosterClick: ((MetaPreview) -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val uiState by CatalogRepository.uiState.collectAsStateWithLifecycle()
|
||||
val gridState = rememberLazyGridState()
|
||||
var headerHeightPx by remember { mutableIntStateOf(0) }
|
||||
|
||||
LaunchedEffect(manifestUrl, type, catalogId, genre, supportsPagination) {
|
||||
CatalogRepository.load(
|
||||
manifestUrl = manifestUrl,
|
||||
type = type,
|
||||
catalogId = catalogId,
|
||||
genre = genre,
|
||||
supportsPagination = supportsPagination,
|
||||
force = true,
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(gridState, uiState.canLoadMore, uiState.isLoading) {
|
||||
snapshotFlow { gridState.layoutInfo }
|
||||
.map { layoutInfo ->
|
||||
val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
|
||||
lastVisible >= layoutInfo.totalItemsCount - 6
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.filter { it && uiState.canLoadMore && !uiState.isLoading }
|
||||
.collect {
|
||||
CatalogRepository.loadMore()
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(3),
|
||||
state = gridState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(
|
||||
start = 16.dp,
|
||||
top = with(androidx.compose.ui.platform.LocalDensity.current) { headerHeightPx.toDp() } + 12.dp,
|
||||
end = 16.dp,
|
||||
bottom = nuvioPlatformExtraBottomPadding + 28.dp,
|
||||
),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
if (uiState.items.isEmpty() && uiState.isLoading) {
|
||||
items(9) { CatalogSkeletonTile() }
|
||||
} else if (uiState.items.isEmpty()) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
CatalogEmptyState(errorMessage = uiState.errorMessage)
|
||||
}
|
||||
} else {
|
||||
items(
|
||||
items = uiState.items,
|
||||
key = { item -> "${item.type}:${item.id}" },
|
||||
) { item ->
|
||||
CatalogPosterTile(
|
||||
item = item,
|
||||
onClick = onPosterClick?.let { { it(item) } },
|
||||
)
|
||||
}
|
||||
if (uiState.isLoading) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
CatalogLoadingFooter()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CatalogHeader(
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
modifier = Modifier.onSizeChanged { headerHeightPx = it.height },
|
||||
onBack = onBack,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CatalogHeader(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(top = 52.dp, bottom = 12.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.clickable(onClick = onBack),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (subtitle.isNotBlank()) {
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CatalogPosterTile(
|
||||
item: MetaPreview,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
Column(
|
||||
modifier = if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(item.posterShape.catalogAspectRatio())
|
||||
.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 CatalogSkeletonTile() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(0.68f)
|
||||
.clip(RoundedCornerShape(22.dp))
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CatalogEmptyState(errorMessage: String?) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 48.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "No titles found",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Text(
|
||||
text = errorMessage ?: "This catalog did not return any items.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CatalogLoadingFooter() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 12.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(22.dp),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun PosterShape.catalogAspectRatio(): Float =
|
||||
when (this) {
|
||||
PosterShape.Poster -> 0.68f
|
||||
PosterShape.Square -> 1f
|
||||
PosterShape.Landscape -> 1.2f
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package com.nuvio.app.features.home
|
||||
|
||||
import com.nuvio.app.features.addons.ManagedAddon
|
||||
import com.nuvio.app.features.catalog.CATALOG_PAGE_SIZE
|
||||
|
||||
data class MetaPreview(
|
||||
val id: String,
|
||||
|
|
@ -29,8 +30,12 @@ data class HomeCatalogSection(
|
|||
val manifestUrl: String,
|
||||
val catalogId: String,
|
||||
val items: List<MetaPreview>,
|
||||
val supportsPagination: Boolean = false,
|
||||
)
|
||||
|
||||
fun HomeCatalogSection.canOpenCatalog(previewLimit: Int): Boolean =
|
||||
items.size > previewLimit || (supportsPagination && items.size >= CATALOG_PAGE_SIZE)
|
||||
|
||||
data class HomeUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val sections: List<HomeCatalogSection> = emptyList(),
|
||||
|
|
@ -42,4 +47,5 @@ internal data class CatalogRequest(
|
|||
val catalogId: String,
|
||||
val catalogName: String,
|
||||
val type: String,
|
||||
val supportsPagination: Boolean,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
package com.nuvio.app.features.home
|
||||
|
||||
import com.nuvio.app.features.addons.ManagedAddon
|
||||
import com.nuvio.app.features.addons.httpGetText
|
||||
import com.nuvio.app.features.catalog.fetchCatalogPage
|
||||
import com.nuvio.app.features.catalog.supportsPagination
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
|
|
@ -70,19 +71,19 @@ object HomeRepository {
|
|||
catalogId = catalog.id,
|
||||
catalogName = catalog.name,
|
||||
type = catalog.type,
|
||||
supportsPagination = catalog.supportsPagination(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun CatalogRequest.toSection(): HomeCatalogSection {
|
||||
val manifest = requireNotNull(addon.manifest)
|
||||
val catalogUrl = buildCatalogUrl(
|
||||
val page = fetchCatalogPage(
|
||||
manifestUrl = manifest.transportUrl,
|
||||
type = type,
|
||||
catalogId = catalogId,
|
||||
)
|
||||
val payload = httpGetText(catalogUrl)
|
||||
val items = HomeCatalogParser.parseCatalog(payload)
|
||||
val items = page.items
|
||||
require(items.isNotEmpty()) { "No feed items returned for $catalogName." }
|
||||
|
||||
return HomeCatalogSection(
|
||||
|
|
@ -94,21 +95,11 @@ object HomeRepository {
|
|||
manifestUrl = manifest.transportUrl,
|
||||
catalogId = catalogId,
|
||||
items = items,
|
||||
supportsPagination = supportsPagination,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildCatalogUrl(
|
||||
manifestUrl: String,
|
||||
type: String,
|
||||
catalogId: String,
|
||||
): String {
|
||||
val baseUrl = manifestUrl
|
||||
.substringBefore("?")
|
||||
.removeSuffix("/manifest.json")
|
||||
return "$baseUrl/catalog/$type/$catalogId.json"
|
||||
}
|
||||
|
||||
private fun String.displayLabel(): String =
|
||||
replaceFirstChar { char ->
|
||||
if (char.isLowerCase()) char.titlecase() else char.toString()
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import com.nuvio.app.features.home.components.HomeSkeletonRow
|
|||
@Composable
|
||||
fun HomeScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
onCatalogClick: ((HomeCatalogSection) -> Unit)? = null,
|
||||
onPosterClick: ((MetaPreview) -> Unit)? = null,
|
||||
) {
|
||||
LaunchedEffect(Unit) {
|
||||
|
|
@ -80,9 +81,16 @@ fun HomeScreen(
|
|||
count = homeUiState.sections.size,
|
||||
key = { index -> homeUiState.sections[index].key },
|
||||
) { index ->
|
||||
val section = homeUiState.sections[index]
|
||||
HomeCatalogRowSection(
|
||||
section = homeUiState.sections[index],
|
||||
section = section,
|
||||
entries = section.items.take(HOME_CATALOG_PREVIEW_LIMIT),
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
onViewAllClick = if (section.canOpenCatalog(HOME_CATALOG_PREVIEW_LIMIT)) {
|
||||
onCatalogClick?.let { { it(section) } }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onPosterClick = onPosterClick,
|
||||
)
|
||||
}
|
||||
|
|
@ -90,3 +98,5 @@ fun HomeScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val HOME_CATALOG_PREVIEW_LIMIT = 18
|
||||
|
|
|
|||
|
|
@ -12,12 +12,13 @@ import com.nuvio.app.features.home.MetaPreview
|
|||
fun HomeCatalogRowSection(
|
||||
section: HomeCatalogSection,
|
||||
modifier: Modifier = Modifier,
|
||||
entries: List<MetaPreview> = section.items,
|
||||
onViewAllClick: (() -> Unit)? = null,
|
||||
onPosterClick: ((MetaPreview) -> Unit)? = null,
|
||||
) {
|
||||
NuvioShelfSection(
|
||||
title = section.title,
|
||||
entries = section.items,
|
||||
entries = entries,
|
||||
modifier = modifier,
|
||||
headerHorizontalPadding = 16.dp,
|
||||
rowContentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 16.dp),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.nuvio.app.features.search
|
||||
|
||||
import com.nuvio.app.features.home.MetaPreview
|
||||
import com.nuvio.app.features.home.HomeCatalogSection
|
||||
|
||||
enum class SearchEmptyStateReason {
|
||||
|
|
@ -15,3 +16,44 @@ data class SearchUiState(
|
|||
val emptyStateReason: SearchEmptyStateReason? = null,
|
||||
val errorMessage: String? = null,
|
||||
)
|
||||
|
||||
enum class DiscoverEmptyStateReason {
|
||||
NoActiveAddons,
|
||||
NoDiscoverCatalogs,
|
||||
NoResults,
|
||||
RequestFailed,
|
||||
}
|
||||
|
||||
data class DiscoverCatalogOption(
|
||||
val key: String,
|
||||
val addonName: String,
|
||||
val manifestUrl: String,
|
||||
val type: String,
|
||||
val catalogId: String,
|
||||
val catalogName: String,
|
||||
val genreOptions: List<String> = emptyList(),
|
||||
val genreRequired: Boolean = false,
|
||||
val supportsPagination: Boolean = false,
|
||||
)
|
||||
|
||||
data class DiscoverUiState(
|
||||
val typeOptions: List<String> = emptyList(),
|
||||
val selectedType: String? = null,
|
||||
val catalogOptions: List<DiscoverCatalogOption> = emptyList(),
|
||||
val selectedCatalogKey: String? = null,
|
||||
val selectedGenre: String? = null,
|
||||
val items: List<MetaPreview> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val nextSkip: Int? = null,
|
||||
val emptyStateReason: DiscoverEmptyStateReason? = null,
|
||||
val errorMessage: String? = null,
|
||||
) {
|
||||
val selectedCatalog: DiscoverCatalogOption?
|
||||
get() = catalogOptions.firstOrNull { it.key == selectedCatalogKey }
|
||||
|
||||
val genreOptions: List<String>
|
||||
get() = selectedCatalog?.genreOptions.orEmpty()
|
||||
|
||||
val canLoadMore: Boolean
|
||||
get() = nextSkip != null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
package com.nuvio.app.features.search
|
||||
|
||||
import com.nuvio.app.features.addons.AddonCatalog
|
||||
import com.nuvio.app.features.addons.AddonExtraProperty
|
||||
import com.nuvio.app.features.addons.ManagedAddon
|
||||
import com.nuvio.app.features.addons.httpGetText
|
||||
import com.nuvio.app.features.home.HomeCatalogParser
|
||||
import com.nuvio.app.features.catalog.fetchCatalogPage
|
||||
import com.nuvio.app.features.catalog.mergeCatalogItems
|
||||
import com.nuvio.app.features.catalog.supportsPagination
|
||||
import com.nuvio.app.features.home.HomeCatalogSection
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
@ -20,9 +22,13 @@ object SearchRepository {
|
|||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val _uiState = MutableStateFlow(SearchUiState())
|
||||
val uiState: StateFlow<SearchUiState> = _uiState.asStateFlow()
|
||||
private val _discoverUiState = MutableStateFlow(DiscoverUiState())
|
||||
val discoverUiState: StateFlow<DiscoverUiState> = _discoverUiState.asStateFlow()
|
||||
|
||||
private var activeJob: Job? = null
|
||||
private var activeDiscoverJob: Job? = null
|
||||
private var lastRequestKey: String? = null
|
||||
private var discoverSources: List<DiscoverCatalogOption> = emptyList()
|
||||
|
||||
fun search(query: String, addons: List<ManagedAddon>) {
|
||||
val normalizedQuery = query.trim()
|
||||
|
|
@ -101,6 +107,126 @@ object SearchRepository {
|
|||
_uiState.value = SearchUiState()
|
||||
}
|
||||
|
||||
fun refreshDiscover(addons: List<ManagedAddon>) {
|
||||
val activeAddons = addons.filter { it.manifest != null }
|
||||
if (activeAddons.isEmpty()) {
|
||||
activeDiscoverJob?.cancel()
|
||||
discoverSources = emptyList()
|
||||
_discoverUiState.value = DiscoverUiState(
|
||||
emptyStateReason = DiscoverEmptyStateReason.NoActiveAddons,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val sources = buildDiscoverSources(activeAddons)
|
||||
discoverSources = sources
|
||||
if (sources.isEmpty()) {
|
||||
activeDiscoverJob?.cancel()
|
||||
_discoverUiState.value = DiscoverUiState(
|
||||
emptyStateReason = DiscoverEmptyStateReason.NoDiscoverCatalogs,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val current = _discoverUiState.value
|
||||
val typeOptions = sources.map { it.type }.distinct().sortedBy { it.typeSortKey() }
|
||||
val selectedType = current.selectedType
|
||||
?.takeIf { type -> typeOptions.contains(type) }
|
||||
?: typeOptions.first()
|
||||
val catalogOptions = sources.filter { it.type == selectedType }
|
||||
val selectedCatalog = catalogOptions.firstOrNull { it.key == current.selectedCatalogKey } ?: catalogOptions.first()
|
||||
val selectedGenre = selectedCatalog.resolveGenreSelection(current.selectedGenre)
|
||||
|
||||
_discoverUiState.value = DiscoverUiState(
|
||||
typeOptions = typeOptions,
|
||||
selectedType = selectedType,
|
||||
catalogOptions = catalogOptions,
|
||||
selectedCatalogKey = selectedCatalog.key,
|
||||
selectedGenre = selectedGenre,
|
||||
items = emptyList(),
|
||||
isLoading = false,
|
||||
nextSkip = null,
|
||||
emptyStateReason = null,
|
||||
errorMessage = null,
|
||||
)
|
||||
|
||||
loadDiscoverFeed(reset = true)
|
||||
}
|
||||
|
||||
fun selectDiscoverType(type: String) {
|
||||
val current = _discoverUiState.value
|
||||
if (current.selectedType == type) return
|
||||
|
||||
val catalogOptions = discoverSources.filter { it.type == type }
|
||||
val selectedCatalog = catalogOptions.firstOrNull() ?: run {
|
||||
_discoverUiState.value = current.copy(
|
||||
selectedType = type,
|
||||
catalogOptions = emptyList(),
|
||||
selectedCatalogKey = null,
|
||||
selectedGenre = null,
|
||||
items = emptyList(),
|
||||
isLoading = false,
|
||||
nextSkip = null,
|
||||
emptyStateReason = DiscoverEmptyStateReason.NoDiscoverCatalogs,
|
||||
errorMessage = null,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
_discoverUiState.value = current.copy(
|
||||
selectedType = type,
|
||||
catalogOptions = catalogOptions,
|
||||
selectedCatalogKey = selectedCatalog.key,
|
||||
selectedGenre = selectedCatalog.resolveGenreSelection(null),
|
||||
items = emptyList(),
|
||||
isLoading = false,
|
||||
nextSkip = null,
|
||||
emptyStateReason = null,
|
||||
errorMessage = null,
|
||||
)
|
||||
loadDiscoverFeed(reset = true)
|
||||
}
|
||||
|
||||
fun selectDiscoverCatalog(catalogKey: String) {
|
||||
val current = _discoverUiState.value
|
||||
if (current.selectedCatalogKey == catalogKey) return
|
||||
|
||||
val selectedCatalog = current.catalogOptions.firstOrNull { it.key == catalogKey } ?: return
|
||||
_discoverUiState.value = current.copy(
|
||||
selectedCatalogKey = selectedCatalog.key,
|
||||
selectedGenre = selectedCatalog.resolveGenreSelection(null),
|
||||
items = emptyList(),
|
||||
isLoading = false,
|
||||
nextSkip = null,
|
||||
emptyStateReason = null,
|
||||
errorMessage = null,
|
||||
)
|
||||
loadDiscoverFeed(reset = true)
|
||||
}
|
||||
|
||||
fun selectDiscoverGenre(genre: String?) {
|
||||
val current = _discoverUiState.value
|
||||
val selectedCatalog = current.selectedCatalog ?: return
|
||||
val normalizedGenre = selectedCatalog.resolveGenreSelection(genre)
|
||||
if (current.selectedGenre == normalizedGenre) return
|
||||
|
||||
_discoverUiState.value = current.copy(
|
||||
selectedGenre = normalizedGenre,
|
||||
items = emptyList(),
|
||||
isLoading = false,
|
||||
nextSkip = null,
|
||||
emptyStateReason = null,
|
||||
errorMessage = null,
|
||||
)
|
||||
loadDiscoverFeed(reset = true)
|
||||
}
|
||||
|
||||
fun loadMoreDiscover() {
|
||||
val current = _discoverUiState.value
|
||||
if (current.isLoading || current.nextSkip == null) return
|
||||
loadDiscoverFeed(reset = false)
|
||||
}
|
||||
|
||||
private fun buildSearchRequests(
|
||||
addons: List<ManagedAddon>,
|
||||
query: String,
|
||||
|
|
@ -118,20 +244,45 @@ object SearchRepository {
|
|||
catalogName = catalog.name,
|
||||
type = catalog.type,
|
||||
query = query,
|
||||
supportsPagination = catalog.supportsPagination(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildDiscoverSources(addons: List<ManagedAddon>): List<DiscoverCatalogOption> =
|
||||
addons.mapNotNull { addon ->
|
||||
val manifest = addon.manifest ?: return@mapNotNull null
|
||||
addon to manifest
|
||||
}.flatMap { (addon, manifest) ->
|
||||
manifest.catalogs
|
||||
.filter { catalog -> catalog.supportsDiscover() }
|
||||
.map { catalog ->
|
||||
val genreExtra = catalog.genreExtra()
|
||||
DiscoverCatalogOption(
|
||||
key = "${manifest.id}:${catalog.type}:${catalog.id}",
|
||||
addonName = manifest.name,
|
||||
manifestUrl = addon.manifestUrl,
|
||||
type = catalog.type,
|
||||
catalogId = catalog.id,
|
||||
catalogName = catalog.name,
|
||||
genreOptions = genreExtra?.options.orEmpty(),
|
||||
genreRequired = genreExtra?.isRequired == true,
|
||||
supportsPagination = catalog.supportsPagination(),
|
||||
)
|
||||
}
|
||||
}.sortedWith(
|
||||
compareBy<DiscoverCatalogOption>({ it.type.typeSortKey() }, { it.addonName.lowercase() }, { it.catalogName.lowercase() }),
|
||||
)
|
||||
|
||||
private suspend fun SearchCatalogRequest.toSection(): HomeCatalogSection {
|
||||
val manifest = requireNotNull(addon.manifest)
|
||||
val catalogUrl = buildSearchCatalogUrl(
|
||||
val page = fetchCatalogPage(
|
||||
manifestUrl = manifest.transportUrl,
|
||||
type = type,
|
||||
catalogId = catalogId,
|
||||
query = query,
|
||||
search = query,
|
||||
)
|
||||
val payload = httpGetText(catalogUrl)
|
||||
val items = HomeCatalogParser.parseCatalog(payload)
|
||||
val items = page.items
|
||||
require(items.isNotEmpty()) { "No search results returned for $catalogName." }
|
||||
|
||||
return HomeCatalogSection(
|
||||
|
|
@ -143,8 +294,68 @@ object SearchRepository {
|
|||
manifestUrl = manifest.transportUrl,
|
||||
catalogId = catalogId,
|
||||
items = items,
|
||||
supportsPagination = supportsPagination,
|
||||
)
|
||||
}
|
||||
|
||||
private fun loadDiscoverFeed(reset: Boolean) {
|
||||
activeDiscoverJob?.cancel()
|
||||
val current = _discoverUiState.value
|
||||
val selectedCatalog = current.selectedCatalog ?: return
|
||||
val requestedSkip = if (reset) 0 else current.nextSkip ?: return
|
||||
|
||||
_discoverUiState.value = current.copy(
|
||||
isLoading = true,
|
||||
items = if (reset) emptyList() else current.items,
|
||||
nextSkip = if (reset) null else current.nextSkip,
|
||||
emptyStateReason = null,
|
||||
errorMessage = null,
|
||||
)
|
||||
|
||||
activeDiscoverJob = scope.launch {
|
||||
runCatching {
|
||||
fetchCatalogPage(
|
||||
manifestUrl = selectedCatalog.manifestUrl,
|
||||
type = selectedCatalog.type,
|
||||
catalogId = selectedCatalog.catalogId,
|
||||
genre = current.selectedGenre,
|
||||
skip = requestedSkip.takeIf { it > 0 },
|
||||
)
|
||||
}.fold(
|
||||
onSuccess = { page ->
|
||||
val latest = _discoverUiState.value
|
||||
if (latest.selectedCatalogKey != selectedCatalog.key || latest.selectedGenre != current.selectedGenre) {
|
||||
return@fold
|
||||
}
|
||||
val mergedItems = if (reset) {
|
||||
page.items
|
||||
} else {
|
||||
mergeCatalogItems(latest.items, page.items)
|
||||
}
|
||||
_discoverUiState.value = latest.copy(
|
||||
items = mergedItems,
|
||||
isLoading = false,
|
||||
nextSkip = if (selectedCatalog.supportsPagination) page.nextSkip else null,
|
||||
emptyStateReason = if (mergedItems.isEmpty()) DiscoverEmptyStateReason.NoResults else null,
|
||||
errorMessage = null,
|
||||
)
|
||||
},
|
||||
onFailure = { error ->
|
||||
val latest = _discoverUiState.value
|
||||
if (latest.selectedCatalogKey != selectedCatalog.key || latest.selectedGenre != current.selectedGenre) {
|
||||
return@fold
|
||||
}
|
||||
_discoverUiState.value = latest.copy(
|
||||
items = if (reset) emptyList() else latest.items,
|
||||
isLoading = false,
|
||||
nextSkip = null,
|
||||
emptyStateReason = DiscoverEmptyStateReason.RequestFailed,
|
||||
errorMessage = error.message ?: "Unable to load discover items.",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class SearchCatalogRequest(
|
||||
|
|
@ -153,50 +364,48 @@ private data class SearchCatalogRequest(
|
|||
val catalogName: String,
|
||||
val type: String,
|
||||
val query: String,
|
||||
val supportsPagination: Boolean,
|
||||
)
|
||||
|
||||
private fun AddonCatalog.supportsSearch(): Boolean =
|
||||
extra.any { property -> property.name == "search" } &&
|
||||
extra.none { property -> property.isRequired && property.name != "search" }
|
||||
|
||||
private fun buildSearchCatalogUrl(
|
||||
manifestUrl: String,
|
||||
type: String,
|
||||
catalogId: String,
|
||||
query: String,
|
||||
): String {
|
||||
val baseUrl = manifestUrl
|
||||
.substringBefore("?")
|
||||
.removeSuffix("/manifest.json")
|
||||
return "$baseUrl/catalog/$type/$catalogId/search=${query.encodeForSearchExtra()}.json"
|
||||
private fun AddonCatalog.supportsDiscover(): Boolean {
|
||||
if (extra.any { property -> property.name == "search" && property.isRequired }) {
|
||||
return false
|
||||
}
|
||||
|
||||
return extra.none { property ->
|
||||
when (property.name) {
|
||||
"genre" -> property.isRequired && property.options.isEmpty()
|
||||
"skip" -> false
|
||||
"search" -> false
|
||||
else -> property.isRequired
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun AddonCatalog.genreExtra(): AddonExtraProperty? =
|
||||
extra.firstOrNull { property -> property.name == "genre" }
|
||||
|
||||
private fun DiscoverCatalogOption.resolveGenreSelection(requestedGenre: String?): String? =
|
||||
when {
|
||||
genreOptions.isEmpty() -> null
|
||||
requestedGenre != null && genreOptions.contains(requestedGenre) -> requestedGenre
|
||||
genreRequired -> genreOptions.firstOrNull()
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun String.displayLabel(): String =
|
||||
replaceFirstChar { char ->
|
||||
if (char.isLowerCase()) char.titlecase() else char.toString()
|
||||
}
|
||||
|
||||
private fun String.encodeForSearchExtra(): String =
|
||||
buildString {
|
||||
encodeToByteArray().forEach { byte ->
|
||||
val value = byte.toInt() and 0xFF
|
||||
val char = value.toChar()
|
||||
if (
|
||||
char in 'a'..'z' ||
|
||||
char in 'A'..'Z' ||
|
||||
char in '0'..'9' ||
|
||||
char == '-' ||
|
||||
char == '_' ||
|
||||
char == '.' ||
|
||||
char == '~'
|
||||
) {
|
||||
append(char)
|
||||
} else {
|
||||
append('%')
|
||||
append(HEX[value shr 4])
|
||||
append(HEX[value and 0x0F])
|
||||
}
|
||||
}
|
||||
private fun String.typeSortKey(): String =
|
||||
when (lowercase()) {
|
||||
"movie" -> "0_movie"
|
||||
"series" -> "1_series"
|
||||
"anime" -> "2_anime"
|
||||
else -> "9_$this"
|
||||
}
|
||||
|
||||
private val HEX = "0123456789ABCDEF"
|
||||
|
|
|
|||
|
|
@ -1,29 +1,65 @@
|
|||
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
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.NuvioScreen
|
||||
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
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
@Composable
|
||||
fun SearchScreen(
|
||||
|
|
@ -36,7 +72,10 @@ fun SearchScreen(
|
|||
|
||||
val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle()
|
||||
val uiState by SearchRepository.uiState.collectAsStateWithLifecycle()
|
||||
var query by rememberSaveable { androidx.compose.runtime.mutableStateOf("") }
|
||||
val discoverUiState by SearchRepository.discoverUiState.collectAsStateWithLifecycle()
|
||||
var query by rememberSaveable { mutableStateOf("") }
|
||||
var headerHeightPx by remember { mutableIntStateOf(0) }
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
val addonRefreshKey = remember(addonsUiState.addons) {
|
||||
addonsUiState.addons.mapNotNull { addon ->
|
||||
|
|
@ -46,7 +85,13 @@ fun SearchScreen(
|
|||
append(':')
|
||||
append(manifest.catalogs.joinToString(separator = ",") { catalog ->
|
||||
val extra = catalog.extra.joinToString(separator = "&") { property ->
|
||||
"${property.name}:${property.isRequired}"
|
||||
buildString {
|
||||
append(property.name)
|
||||
append(':')
|
||||
append(property.isRequired)
|
||||
append(':')
|
||||
append(property.options.joinToString(separator = "|"))
|
||||
}
|
||||
}
|
||||
"${catalog.type}:${catalog.id}:$extra"
|
||||
})
|
||||
|
|
@ -54,6 +99,10 @@ fun SearchScreen(
|
|||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(addonRefreshKey) {
|
||||
SearchRepository.refreshDiscover(addonsUiState.addons)
|
||||
}
|
||||
|
||||
LaunchedEffect(query, addonRefreshKey) {
|
||||
val normalizedQuery = query.trim()
|
||||
if (normalizedQuery.isBlank()) {
|
||||
|
|
@ -67,62 +116,403 @@ fun SearchScreen(
|
|||
}
|
||||
}
|
||||
|
||||
NuvioScreen(
|
||||
modifier = modifier,
|
||||
horizontalPadding = 0.dp,
|
||||
LaunchedEffect(listState, query, discoverUiState.canLoadMore, discoverUiState.isLoading) {
|
||||
if (query.isNotBlank()) return@LaunchedEffect
|
||||
|
||||
snapshotFlow { listState.layoutInfo }
|
||||
.map { layoutInfo ->
|
||||
val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
|
||||
lastVisible >= layoutInfo.totalItemsCount - 4
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.filter { it && discoverUiState.canLoadMore && !discoverUiState.isLoading }
|
||||
.collect {
|
||||
SearchRepository.loadMoreDiscover()
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
stickyHeader {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = 12.dp),
|
||||
) {
|
||||
NuvioScreenHeader(title = "Search")
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
NuvioInputField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
placeholder = "Search installed addon catalogs",
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 18.dp + nuvioPlatformExtraBottomPadding),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(with(androidx.compose.ui.platform.LocalDensity.current) { headerHeightPx.toDp() }))
|
||||
}
|
||||
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
when {
|
||||
uiState.isLoading && uiState.sections.isEmpty() -> {
|
||||
items(2) {
|
||||
HomeSkeletonRow(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
uiState.sections.isEmpty() -> {
|
||||
item {
|
||||
SearchEmptyStateCard(
|
||||
reason = uiState.emptyStateReason,
|
||||
errorMessage = uiState.errorMessage,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
items(
|
||||
items = uiState.sections,
|
||||
key = { section -> section.key },
|
||||
) { section ->
|
||||
HomeCatalogRowSection(
|
||||
section = section,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
onPosterClick = onPosterClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = 12.dp)
|
||||
.onSizeChanged { headerHeightPx = it.height },
|
||||
) {
|
||||
NuvioScreenHeader(title = "Search")
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
NuvioInputField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
placeholder = "Search movies, shows...",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
query.isBlank() -> Unit
|
||||
|
||||
uiState.isLoading && uiState.sections.isEmpty() -> {
|
||||
items(2) {
|
||||
HomeSkeletonRow(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
uiState.sections.isEmpty() -> {
|
||||
item {
|
||||
SearchEmptyStateCard(
|
||||
reason = uiState.emptyStateReason,
|
||||
errorMessage = uiState.errorMessage,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
items(
|
||||
count = uiState.sections.size,
|
||||
key = { index -> uiState.sections[index].key },
|
||||
) { index ->
|
||||
HomeCatalogRowSection(
|
||||
section = uiState.sections[index],
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
onPosterClick = onPosterClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
@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?,
|
||||
|
|
@ -160,3 +550,25 @@ 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