feat: Implement navigation and details screen for media items

- Added navigation support using Jetpack Compose Navigation.
- Created MetaDetailsScreen to display detailed information about media items.
- Introduced MetaDetailsRepository for fetching and managing media details.
- Added components for displaying action buttons, cast, and meta information.
- Updated HomeScreen and HomePosterCard to handle poster click events.
- Integrated new UI components into the existing app structure.
- Added platform-specific insets for better layout handling.
- Updated Gradle dependencies for navigation and serialization support.
This commit is contained in:
tapframe 2026-03-11 21:00:12 +05:30
parent 0732854d3e
commit dde6b2212f
22 changed files with 844 additions and 121 deletions

View file

@ -6,6 +6,7 @@ plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.kotlinxSerialization)
}
kotlin {
@ -44,6 +45,7 @@ kotlin {
implementation(libs.androidx.lifecycle.viewmodelCompose)
implementation(libs.androidx.lifecycle.runtimeCompose)
implementation(libs.kotlinx.serialization.json)
implementation(libs.androidx.navigation.compose)
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin)

View file

@ -4,24 +4,6 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Extension
import androidx.compose.material.icons.rounded.Home
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.nuvio.app.core.ui.NuvioTheme
import com.nuvio.app.features.addons.AddonStorage
class MainActivity : ComponentActivity() {
@ -31,45 +13,7 @@ class MainActivity : ComponentActivity() {
AddonStorage.initialize(applicationContext)
setContent {
AndroidAppRoot()
}
}
}
@Preview
@Composable
fun AppAndroidPreview() {
AndroidAppRoot()
}
@Composable
private fun AndroidAppRoot() {
NuvioTheme {
var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Addons) }
Column(
modifier = Modifier.fillMaxSize(),
) {
AppScreen(
tab = selectedTab,
modifier = Modifier.weight(1f),
)
NavigationBar(
containerColor = MaterialTheme.colorScheme.surface,
) {
NavigationBarItem(
selected = selectedTab == AppScreenTab.Home,
onClick = { selectedTab = AppScreenTab.Home },
icon = { Icon(Icons.Rounded.Home, contentDescription = null) },
label = { Text("Home") },
)
NavigationBarItem(
selected = selectedTab == AppScreenTab.Addons,
onClick = { selectedTab = AppScreenTab.Addons },
icon = { Icon(Icons.Rounded.Extension, contentDescription = null) },
label = { Text("Addons") },
)
}
App()
}
}
}

View file

@ -4,3 +4,4 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
internal actual val nuvioPlatformExtraTopPadding: Dp = 0.dp
internal actual val nuvioPlatformExtraBottomPadding: Dp = 0.dp

View file

@ -1,14 +1,43 @@
package com.nuvio.app
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Extension
import androidx.compose.material.icons.rounded.Home
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
import coil3.ImageLoader
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.details.MetaDetailsRepository
import com.nuvio.app.features.details.MetaDetailsScreen
import com.nuvio.app.features.home.HomeScreen
import com.nuvio.app.features.home.MetaPreview
import kotlinx.serialization.Serializable
@Serializable
object TabsRoute
@Serializable
data class DetailRoute(val type: String, val id: String)
enum class AppScreenTab {
Home,
@ -19,9 +48,13 @@ enum class AppScreenTab {
fun AppScreen(
tab: AppScreenTab,
modifier: Modifier = Modifier,
onPosterClick: ((MetaPreview) -> Unit)? = null,
) {
when (tab) {
AppScreenTab.Home -> HomeScreen(modifier = modifier)
AppScreenTab.Home -> HomeScreen(
modifier = modifier,
onPosterClick = onPosterClick,
)
AppScreenTab.Addons -> AddonsScreen(modifier = modifier)
}
}
@ -35,6 +68,59 @@ fun App() {
.build()
}
NuvioTheme {
AppScreen(tab = AppScreenTab.Addons)
val navController = rememberNavController()
val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route
var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Addons) }
Scaffold(
containerColor = MaterialTheme.colorScheme.background,
bottomBar = {
if (currentRoute == TabsRoute::class.qualifiedName) {
NavigationBar(
containerColor = MaterialTheme.colorScheme.surface,
) {
NavigationBarItem(
selected = selectedTab == AppScreenTab.Home,
onClick = { selectedTab = AppScreenTab.Home },
icon = { Icon(Icons.Rounded.Home, contentDescription = null) },
label = { Text("Home") },
)
NavigationBarItem(
selected = selectedTab == AppScreenTab.Addons,
onClick = { selectedTab = AppScreenTab.Addons },
icon = { Icon(Icons.Rounded.Extension, contentDescription = null) },
label = { Text("Addons") },
)
}
}
},
) { innerPadding ->
NavHost(
navController = navController,
startDestination = TabsRoute,
) {
composable<TabsRoute> {
AppScreen(
tab = selectedTab,
modifier = Modifier.padding(innerPadding),
onPosterClick = { meta ->
navController.navigate(DetailRoute(type = meta.type, id = meta.id))
},
)
}
composable<DetailRoute> { backStackEntry ->
val route = backStackEntry.toRoute<DetailRoute>()
MetaDetailsScreen(
type = route.type,
id = route.id,
onBack = {
MetaDetailsRepository.clear()
navController.popBackStack()
},
modifier = Modifier.padding(innerPadding),
)
}
}
}
}
}

View file

@ -10,16 +10,11 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.shape.CircleShape
@ -53,13 +48,12 @@ fun NuvioScreen(
LazyColumn(
modifier = modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)),
.background(MaterialTheme.colorScheme.background),
contentPadding = PaddingValues(
start = 18.dp,
top = 12.dp + nuvioPlatformExtraTopPadding,
end = 18.dp,
bottom = 22.dp,
bottom = 22.dp + nuvioPlatformExtraBottomPadding,
),
verticalArrangement = Arrangement.spacedBy(16.dp),
content = content,

View file

@ -3,3 +3,4 @@ package com.nuvio.app.core.ui
import androidx.compose.ui.unit.Dp
internal expect val nuvioPlatformExtraTopPadding: Dp
internal expect val nuvioPlatformExtraBottomPadding: Dp

View file

@ -0,0 +1,34 @@
package com.nuvio.app.features.details
data class MetaDetails(
val id: String,
val type: String,
val name: String,
val poster: String? = null,
val background: String? = null,
val logo: String? = null,
val description: String? = null,
val releaseInfo: String? = null,
val imdbRating: String? = null,
val runtime: String? = null,
val genres: List<String> = emptyList(),
val director: List<String> = emptyList(),
val cast: List<String> = emptyList(),
val country: String? = null,
val awards: String? = null,
val language: String? = null,
val website: String? = null,
val links: List<MetaLink> = emptyList(),
)
data class MetaLink(
val name: String,
val category: String,
val url: String,
)
data class MetaDetailsUiState(
val isLoading: Boolean = false,
val meta: MetaDetails? = null,
val errorMessage: String? = null,
)

View file

@ -0,0 +1,59 @@
package com.nuvio.app.features.details
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
internal object MetaDetailsParser {
private val json = Json { ignoreUnknownKeys = true }
fun parse(payload: String): MetaDetails {
val root = json.parseToJsonElement(payload).jsonObject
val meta = root["meta"]?.jsonObject ?: error("Missing 'meta' in response")
return MetaDetails(
id = meta.requiredString("id"),
type = meta.requiredString("type"),
name = meta.requiredString("name"),
poster = meta.string("poster"),
background = meta.string("background"),
logo = meta.string("logo"),
description = meta.string("description"),
releaseInfo = meta.string("releaseInfo"),
imdbRating = meta.string("imdbRating"),
runtime = meta.string("runtime"),
genres = meta.stringList("genres"),
director = meta.stringList("director"),
cast = meta.stringList("cast"),
country = meta.string("country"),
awards = meta.string("awards"),
language = meta.string("language"),
website = meta.string("website"),
links = meta.links(),
)
}
private fun JsonObject.requiredString(name: String): String =
this[name]?.jsonPrimitive?.contentOrNull ?: error("Missing required field '$name'")
private fun JsonObject.string(name: String): String? =
this[name]?.jsonPrimitive?.contentOrNull
private fun JsonObject.array(name: String): JsonArray =
this[name] as? JsonArray ?: JsonArray(emptyList())
private fun JsonObject.stringList(name: String): List<String> =
array(name).mapNotNull { it.jsonPrimitive.contentOrNull?.takeIf(String::isNotBlank) }
private fun JsonObject.links(): List<MetaLink> =
array("links").mapNotNull { element ->
val link = element as? JsonObject ?: return@mapNotNull null
val linkName = link.string("name") ?: return@mapNotNull null
val category = link.string("category") ?: return@mapNotNull null
val url = link.string("url") ?: return@mapNotNull null
MetaLink(name = linkName, category = category, url = url)
}
}

View file

@ -0,0 +1,74 @@
package com.nuvio.app.features.details
import com.nuvio.app.features.addons.AddonManifest
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.httpGetText
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
object MetaDetailsRepository {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _uiState = MutableStateFlow(MetaDetailsUiState())
val uiState: StateFlow<MetaDetailsUiState> = _uiState.asStateFlow()
fun load(type: String, id: String) {
_uiState.value = MetaDetailsUiState(isLoading = true)
scope.launch {
val manifests = AddonRepository.uiState.value.addons
.mapNotNull { it.manifest }
.filter { manifest ->
manifest.resources.any { resource ->
resource.name == "meta" &&
resource.types.contains(type) &&
(resource.idPrefixes.isEmpty() || resource.idPrefixes.any { id.startsWith(it) })
}
}
if (manifests.isEmpty()) {
_uiState.value = MetaDetailsUiState(
errorMessage = "No addon provides meta for this content.",
)
return@launch
}
for (manifest in manifests) {
val result = tryFetchMeta(manifest, type, id)
if (result != null) {
_uiState.value = MetaDetailsUiState(meta = result)
return@launch
}
}
_uiState.value = MetaDetailsUiState(
errorMessage = "Could not load details from any addon.",
)
}
}
fun clear() {
_uiState.value = MetaDetailsUiState()
}
private suspend fun tryFetchMeta(
manifest: AddonManifest,
type: String,
id: String,
): MetaDetails? {
return try {
val baseUrl = manifest.transportUrl
.substringBefore("?")
.removeSuffix("/manifest.json")
val url = "$baseUrl/meta/$type/$id.json"
val payload = httpGetText(url)
MetaDetailsParser.parse(payload)
} catch (_: Throwable) {
null
}
}
}

View file

@ -0,0 +1,136 @@
package com.nuvio.app.features.details
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.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.ui.nuvioPlatformExtraBottomPadding
import com.nuvio.app.features.details.components.DetailActionButtons
import com.nuvio.app.features.details.components.DetailCastSection
import com.nuvio.app.features.details.components.DetailHero
import com.nuvio.app.features.details.components.DetailMetaInfo
@Composable
fun MetaDetailsScreen(
type: String,
id: String,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
val uiState by MetaDetailsRepository.uiState.collectAsStateWithLifecycle()
LaunchedEffect(type, id) {
MetaDetailsRepository.load(type, id)
}
Box(
modifier = modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
) {
when {
uiState.isLoading -> {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center),
color = MaterialTheme.colorScheme.primary,
)
}
uiState.errorMessage != null -> {
Column(
modifier = Modifier
.align(Alignment.Center)
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "Failed to load",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
)
Text(
text = uiState.errorMessage.orEmpty(),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
uiState.meta != null -> {
val meta = uiState.meta!!
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
) {
DetailHero(meta = meta)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 18.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
DetailActionButtons()
DetailMetaInfo(meta = meta)
DetailCastSection(cast = meta.cast)
Spacer(modifier = Modifier.height(32.dp + nuvioPlatformExtraBottomPadding))
}
}
}
}
// Back button overlay
Box(
modifier = Modifier
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top))
.padding(start = 12.dp, top = 8.dp)
.size(40.dp)
.background(
color = MaterialTheme.colorScheme.background.copy(alpha = 0.5f),
shape = CircleShape,
)
.clickable(onClick = onBack),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = "Back",
tint = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.size(22.dp),
)
}
}
}

View file

@ -0,0 +1,81 @@
package com.nuvio.app.features.details.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BookmarkBorder
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun DetailActionButtons(
modifier: Modifier = Modifier,
onPlayClick: () -> Unit = {},
onSaveClick: () -> Unit = {},
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Button(
onClick = onPlayClick,
modifier = Modifier
.weight(1f)
.height(50.dp),
shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.onBackground,
contentColor = MaterialTheme.colorScheme.background,
),
) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = null,
modifier = Modifier.size(22.dp),
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = "Play",
style = MaterialTheme.typography.titleMedium,
)
}
OutlinedButton(
onClick = onSaveClick,
modifier = Modifier
.weight(1f)
.height(50.dp),
shape = RoundedCornerShape(14.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
) {
Icon(
imageVector = Icons.Default.BookmarkBorder,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = "Save",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
}

View file

@ -0,0 +1,94 @@
package com.nuvio.app.features.details.components
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.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@Composable
fun DetailCastSection(
cast: List<String>,
modifier: Modifier = Modifier,
) {
if (cast.isEmpty()) return
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(
text = "Cast",
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.onBackground,
)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(20.dp),
) {
items(cast) { name ->
CastItem(name = name)
}
}
}
}
@Composable
private fun CastItem(
name: String,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
modifier = Modifier
.size(64.dp)
.background(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = CircleShape,
),
contentAlignment = Alignment.Center,
) {
Text(
text = name.initials(),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold,
)
}
Text(
text = name,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
private fun String.initials(): String {
val parts = trim().split(" ").filter { it.isNotBlank() }
return when {
parts.size >= 2 -> "${parts.first().first().uppercaseChar()}${parts.last().first().uppercaseChar()}"
parts.size == 1 -> parts.first().take(2).uppercase()
else -> ""
}
}

View file

@ -0,0 +1,112 @@
package com.nuvio.app.features.details.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.nuvio.app.features.details.MetaDetails
@Composable
fun DetailHero(
meta: MetaDetails,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier.fillMaxWidth(),
) {
val imageUrl = meta.background ?: meta.poster
if (imageUrl != null) {
AsyncImage(
model = imageUrl,
contentDescription = meta.name,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(0.75f),
contentScale = ContentScale.Crop,
)
} else {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(0.75f)
.background(MaterialTheme.colorScheme.surface),
)
}
// Gradient overlay at the bottom
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(0.75f),
contentAlignment = Alignment.BottomCenter,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(260.dp)
.align(Alignment.BottomCenter)
.background(
Brush.verticalGradient(
colors = listOf(
Color.Transparent,
MaterialTheme.colorScheme.background.copy(alpha = 0.7f),
MaterialTheme.colorScheme.background,
),
),
),
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 18.dp)
.padding(bottom = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (meta.logo != null) {
AsyncImage(
model = meta.logo,
contentDescription = "${meta.name} logo",
modifier = Modifier
.fillMaxWidth(0.6f)
.height(80.dp),
contentScale = ContentScale.Fit,
)
} else {
Text(
text = meta.name,
style = MaterialTheme.typography.displayLarge,
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center,
)
}
if (meta.genres.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = meta.genres.take(3).joinToString(" \u2022 "),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
}
}
}

View file

@ -0,0 +1,133 @@
package com.nuvio.app.features.details.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
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.graphics.Color
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 com.nuvio.app.features.details.MetaDetails
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun DetailMetaInfo(
meta: MetaDetails,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
// Year, Runtime, IMDb rating row
val infoParts = buildList {
meta.releaseInfo?.let { add(it) }
meta.runtime?.let { add(it.uppercase()) }
}
if (infoParts.isNotEmpty() || meta.imdbRating != null) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
infoParts.forEach { part ->
Text(
text = part,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
fontWeight = FontWeight.Bold,
)
}
if (meta.imdbRating != null) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Surface(
shape = RoundedCornerShape(4.dp),
color = ImdbYellow,
) {
Text(
text = "IMDb",
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
style = MaterialTheme.typography.labelMedium.copy(
fontSize = 10.sp,
fontWeight = FontWeight.Black,
letterSpacing = 0.sp,
),
color = ImdbBlack,
)
}
Spacer(modifier = Modifier.width(5.dp))
Text(
text = meta.imdbRating,
style = MaterialTheme.typography.titleMedium,
color = ImdbYellow,
fontWeight = FontWeight.Bold,
)
}
}
}
}
// Director
if (meta.director.isNotEmpty()) {
Row {
Text(
text = "Director: ",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.SemiBold,
)
Text(
text = meta.director.joinToString(", "),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
// Description
if (!meta.description.isNullOrBlank()) {
var expanded by remember { mutableStateOf(false) }
Column {
Text(
text = meta.description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = if (expanded) Int.MAX_VALUE else 3,
overflow = TextOverflow.Ellipsis,
lineHeight = 22.sp,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = if (expanded) "Show Less" else "Show More ▾",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.clickable { expanded = !expanded },
)
}
}
}
}
private val ImdbYellow = Color(0xFFF5C518)
private val ImdbBlack = Color(0xFF000000)

View file

@ -18,6 +18,7 @@ import com.nuvio.app.features.home.components.HomeEmptyStateCard
@Composable
fun HomeScreen(
modifier: Modifier = Modifier,
onPosterClick: ((MetaPreview) -> Unit)? = null,
) {
LaunchedEffect(Unit) {
AddonRepository.initialize()
@ -92,6 +93,7 @@ fun HomeScreen(
) { index ->
HomeCatalogRowSection(
section = homeUiState.sections[index],
onPosterClick = onPosterClick,
)
}
}

View file

@ -10,12 +10,14 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.nuvio.app.features.home.HomeCatalogSection
import com.nuvio.app.features.home.MetaPreview
@Composable
fun HomeCatalogRowSection(
section: HomeCatalogSection,
modifier: Modifier = Modifier,
onViewAllClick: (() -> Unit)? = null,
onPosterClick: ((MetaPreview) -> Unit)? = null,
) {
Column(
modifier = modifier.fillMaxWidth(),
@ -33,7 +35,10 @@ fun HomeCatalogRowSection(
items = section.items,
key = { item -> item.id },
) { item ->
HomePosterCard(item = item)
HomePosterCard(
item = item,
onClick = onPosterClick?.let { { it(item) } },
)
}
}
}

View file

@ -1,6 +1,7 @@
package com.nuvio.app.features.home.components
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
@ -28,9 +29,12 @@ import com.nuvio.app.features.home.PosterShape
fun HomePosterCard(
item: MetaPreview,
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
) {
Column(
modifier = modifier.width(142.dp),
modifier = modifier
.width(142.dp)
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Box(

View file

@ -2,18 +2,11 @@ package com.nuvio.app
import androidx.compose.ui.window.ComposeUIViewController
import platform.UIKit.UIColor
import com.nuvio.app.core.ui.NuvioTheme
private fun nuvioViewController(
tab: AppScreenTab,
) = ComposeUIViewController {
NuvioTheme {
AppScreen(tab = tab)
}
private val nuvioBackgroundColor = UIColor(red = 0.008, green = 0.016, blue = 0.016, alpha = 1.0)
fun MainViewController() = ComposeUIViewController {
App()
}.apply {
view.backgroundColor = UIColor(red = 0.008, green = 0.016, blue = 0.016, alpha = 1.0)
view.backgroundColor = nuvioBackgroundColor
}
fun HomeViewController() = nuvioViewController(tab = AppScreenTab.Home)
fun AddonsViewController() = nuvioViewController(tab = AppScreenTab.Addons)

View file

@ -4,3 +4,4 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
internal actual val nuvioPlatformExtraTopPadding: Dp = 0.dp
internal actual val nuvioPlatformExtraBottomPadding: Dp = 0.dp

View file

@ -4,6 +4,7 @@ android-compileSdk = "36"
android-minSdk = "24"
android-targetSdk = "36"
androidx-activity = "1.12.2"
androidx-navigation = "2.9.0"
androidx-appcompat = "1.7.1"
androidx-core = "1.17.0"
androidx-espresso = "3.7.0"
@ -26,6 +27,7 @@ androidx-testExt-junit = { module = "androidx.test.ext:junit", version.ref = "an
androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso" }
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
androidx-navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "androidx-navigation" }
compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" }
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
@ -47,3 +49,4 @@ androidLibrary = { id = "com.android.library", version.ref = "agp" }
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlinxSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

View file

@ -2,19 +2,9 @@ import UIKit
import SwiftUI
import ComposeApp
struct HomeComposeView: UIViewControllerRepresentable {
struct ComposeView: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> UIViewController {
let controller = MainViewControllerKt.HomeViewController()
controller.view.backgroundColor = UIColor(red: 0.008, green: 0.016, blue: 0.016, alpha: 1.0)
return controller
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
}
struct AddonsComposeView: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> UIViewController {
let controller = MainViewControllerKt.AddonsViewController()
let controller = MainViewControllerKt.MainViewController()
controller.view.backgroundColor = UIColor(red: 0.008, green: 0.016, blue: 0.016, alpha: 1.0)
return controller
}
@ -24,22 +14,7 @@ struct AddonsComposeView: UIViewControllerRepresentable {
struct ContentView: View {
var body: some View {
TabView {
HomeComposeView()
.ignoresSafeArea()
.tabItem {
Label("Home", systemImage: "house.fill")
}
AddonsComposeView()
.ignoresSafeArea()
.tabItem {
Label("Addons", systemImage: "puzzlepiece.extension.fill")
}
}
.background(Color(red: 0.008, green: 0.016, blue: 0.016).ignoresSafeArea())
.toolbarBackground(Color(red: 0.039, green: 0.051, blue: 0.051), for: .tabBar)
.toolbarBackground(.visible, for: .tabBar)
.toolbarColorScheme(.dark, for: .tabBar)
ComposeView()
.ignoresSafeArea()
}
}

View file

@ -1,18 +1,7 @@
import SwiftUI
import UIKit
@main
struct iOSApp: App {
init() {
let tabAppearance = UITabBarAppearance()
tabAppearance.configureWithOpaqueBackground()
tabAppearance.backgroundColor = UIColor(red: 0.039, green: 0.051, blue: 0.051, alpha: 1.0)
UITabBar.appearance().standardAppearance = tabAppearance
UITabBar.appearance().scrollEdgeAppearance = tabAppearance
UITabBar.appearance().unselectedItemTintColor = UIColor(white: 0.58, alpha: 1.0)
}
var body: some Scene {
WindowGroup {
ContentView()