streamscreen init

This commit is contained in:
tapframe 2026-03-11 21:35:12 +05:30
parent bafae15f7e
commit 9d5d5fa4e2
20 changed files with 1924 additions and 16 deletions

1
.gitignore vendored
View file

@ -17,4 +17,5 @@ captures
!*.xcworkspace/contents.xcworkspacedata
**/xcshareddata/WorkspaceSettings.xcsettings
node_modules/
logs/
Docs

View file

@ -46,6 +46,7 @@ kotlin {
implementation(libs.androidx.lifecycle.runtimeCompose)
implementation(libs.kotlinx.serialization.json)
implementation(libs.androidx.navigation.compose)
implementation(libs.kermit)
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin)

View file

@ -6,4 +6,6 @@ class AndroidPlatform : Platform {
override val name: String = "Android ${Build.VERSION.SDK_INT}"
}
actual fun getPlatform(): Platform = AndroidPlatform()
actual fun getPlatform(): Platform = AndroidPlatform()
internal actual val isIos: Boolean = false

View file

@ -1,22 +1,30 @@
package com.nuvio.app
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Extension
import androidx.compose.material.icons.rounded.Home
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
@ -32,6 +40,8 @@ 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 com.nuvio.app.features.streams.StreamsRepository
import com.nuvio.app.features.streams.StreamsScreen
import kotlinx.serialization.Serializable
@Serializable
@ -40,6 +50,20 @@ object TabsRoute
@Serializable
data class DetailRoute(val type: String, val id: String)
@Serializable
data class StreamRoute(
val type: String,
val videoId: String,
val title: String,
val logo: String? = null,
val poster: String? = null,
val background: String? = null,
val seasonNumber: Int? = null,
val episodeNumber: Int? = null,
val episodeTitle: String? = null,
val episodeThumbnail: String? = null,
)
enum class AppScreenTab {
Home,
Addons,
@ -60,6 +84,7 @@ fun AppScreen(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@Preview
fun App() {
@ -73,6 +98,31 @@ fun App() {
val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route
var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) }
// iOS-only: StreamsScreen is presented as a native modal sheet instead of
// a NavHost destination. On Android this stays null and navController is used.
var pendingStream by remember { mutableStateOf<StreamRoute?>(null) }
val onPlay: (String, String, String, String?, String?, String?, Int?, Int?, String?, String?) -> Unit =
{ type, videoId, title, logo, poster, background, seasonNumber, episodeNumber, episodeTitle, episodeThumbnail ->
val route = StreamRoute(
type = type,
videoId = videoId,
title = title,
logo = logo,
poster = poster,
background = background,
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
episodeTitle = episodeTitle,
episodeThumbnail = episodeThumbnail,
)
if (isIos) {
pendingStream = route
} else {
navController.navigate(route)
}
}
Scaffold(
containerColor = MaterialTheme.colorScheme.background,
contentWindowInsets = WindowInsets(0),
@ -80,6 +130,7 @@ fun App() {
if (currentRoute == TabsRoute::class.qualifiedName) {
NavigationBar(
containerColor = MaterialTheme.colorScheme.surface,
windowInsets = WindowInsets(0),
) {
NavigationBarItem(
selected = selectedTab == AppScreenTab.Home,
@ -119,10 +170,64 @@ fun App() {
MetaDetailsRepository.clear()
navController.popBackStack()
},
onPlay = onPlay,
modifier = Modifier.padding(innerPadding),
)
}
// Android only: iOS uses the modal sheet below instead
composable<StreamRoute> { backStackEntry ->
val route = backStackEntry.toRoute<StreamRoute>()
StreamsScreen(
type = route.type,
videoId = route.videoId,
title = route.title,
logo = route.logo,
poster = route.poster,
background = route.background,
seasonNumber = route.seasonNumber,
episodeNumber = route.episodeNumber,
episodeTitle = route.episodeTitle,
episodeThumbnail = route.episodeThumbnail,
onBack = {
StreamsRepository.clear()
navController.popBackStack()
},
modifier = Modifier.padding(innerPadding),
)
}
}
}
// iOS native modal sheet presentation for StreamsScreen
pendingStream?.let { route ->
ModalBottomSheet(
onDismissRequest = {
StreamsRepository.clear()
pendingStream = null
},
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onBackground,
dragHandle = null,
contentWindowInsets = { WindowInsets.safeDrawing.only(WindowInsetsSides.Top) },
) {
StreamsScreen(
type = route.type,
videoId = route.videoId,
title = route.title,
logo = route.logo,
poster = route.poster,
background = route.background,
seasonNumber = route.seasonNumber,
episodeNumber = route.episodeNumber,
episodeTitle = route.episodeTitle,
episodeThumbnail = route.episodeThumbnail,
onBack = {
StreamsRepository.clear()
pendingStream = null
},
)
}
}
}
}

View file

@ -4,4 +4,6 @@ interface Platform {
val name: String
}
expect fun getPlatform(): Platform
expect fun getPlatform(): Platform
internal expect val isIos: Boolean

View file

@ -61,14 +61,12 @@ object AddonRepository {
_uiState.update { current ->
current.copy(
addons = listOf(
ManagedAddon(
manifestUrl = manifestUrl,
manifest = manifest,
isRefreshing = false,
errorMessage = null,
),
) + current.addons,
addons = current.addons + ManagedAddon(
manifestUrl = manifestUrl,
manifest = manifest,
isRefreshing = false,
errorMessage = null,
),
)
}
persist()

View file

@ -19,6 +19,7 @@ data class MetaDetails(
val language: String? = null,
val website: String? = null,
val links: List<MetaLink> = emptyList(),
val videos: List<MetaVideo> = emptyList(),
)
data class MetaLink(
@ -27,6 +28,16 @@ data class MetaLink(
val url: String,
)
data class MetaVideo(
val id: String,
val title: String,
val released: String? = null,
val thumbnail: String? = null,
val season: Int? = null,
val episode: Int? = null,
val overview: String? = null,
)
data class MetaDetailsUiState(
val isLoading: Boolean = false,
val meta: MetaDetails? = null,

View file

@ -4,6 +4,7 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
@ -33,6 +34,7 @@ internal object MetaDetailsParser {
language = meta.string("language"),
website = meta.string("website"),
links = meta.links(),
videos = meta.videos(),
)
}
@ -56,4 +58,23 @@ internal object MetaDetailsParser {
val url = link.string("url") ?: return@mapNotNull null
MetaLink(name = linkName, category = category, url = url)
}
private fun JsonObject.int(name: String): Int? =
this[name]?.jsonPrimitive?.intOrNull
private fun JsonObject.videos(): List<MetaVideo> =
array("videos").mapNotNull { element ->
val video = element as? JsonObject ?: return@mapNotNull null
val id = video.string("id") ?: return@mapNotNull null
val title = video.string("title") ?: video.string("name") ?: return@mapNotNull null
MetaVideo(
id = id,
title = title,
released = video.string("released"),
thumbnail = video.string("thumbnail"),
season = video.int("season"),
episode = video.int("episode"),
overview = video.string("overview") ?: video.string("description"),
)
}
}

View file

@ -1,5 +1,6 @@
package com.nuvio.app.features.details
import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.AddonManifest
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.httpGetText
@ -12,11 +13,13 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
object MetaDetailsRepository {
private val log = Logger.withTag("MetaDetailsRepo")
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _uiState = MutableStateFlow(MetaDetailsUiState())
val uiState: StateFlow<MetaDetailsUiState> = _uiState.asStateFlow()
fun load(type: String, id: String) {
log.d { "load() called — type=$type id=$id" }
_uiState.value = MetaDetailsUiState(isLoading = true)
scope.launch {
@ -31,6 +34,7 @@ object MetaDetailsRepository {
}
if (manifests.isEmpty()) {
log.w { "No addon provides meta for type=$type id=$id" }
_uiState.value = MetaDetailsUiState(
errorMessage = "No addon provides meta for this content.",
)
@ -65,9 +69,18 @@ object MetaDetailsRepository {
.substringBefore("?")
.removeSuffix("/manifest.json")
val url = "$baseUrl/meta/$type/$id.json"
log.d { "Fetching meta from: $url" }
val payload = httpGetText(url)
MetaDetailsParser.parse(payload)
} catch (_: Throwable) {
log.d { "Raw payload length=${payload.length}, first 500 chars: ${payload.take(500)}" }
val result = MetaDetailsParser.parse(payload)
log.d { "Parsed meta: type=${result.type}, name=${result.name}, videos=${result.videos.size}" }
if (result.videos.isNotEmpty()) {
val first = result.videos.first()
log.d { "First video: id=${first.id} title=${first.title} s=${first.season} e=${first.episode}" }
}
result
} catch (e: Throwable) {
log.e(e) { "Failed to fetch/parse meta from ${manifest.transportUrl}" }
null
}
}

View file

@ -38,12 +38,14 @@ 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
import com.nuvio.app.features.details.components.DetailSeriesContent
@Composable
fun MetaDetailsScreen(
type: String,
id: String,
onBack: () -> Unit,
onPlay: ((type: String, videoId: String, title: String, logo: String?, poster: String?, background: String?, seasonNumber: Int?, episodeNumber: Int?, episodeTitle: String?, episodeThumbnail: String?) -> Unit)? = null,
modifier: Modifier = Modifier,
) {
val uiState by MetaDetailsRepository.uiState.collectAsStateWithLifecycle()
@ -102,12 +104,52 @@ fun MetaDetailsScreen(
.padding(horizontal = 18.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
DetailActionButtons()
DetailActionButtons(
onPlayClick = {
onPlay?.invoke(
meta.type,
meta.id,
meta.name,
meta.logo,
meta.poster,
meta.background,
null,
null,
null,
null,
)
},
)
DetailMetaInfo(meta = meta)
DetailCastSection(cast = meta.cast)
DetailSeriesContent(
meta = meta,
onEpisodeClick = { video ->
val season = video.season
val episode = video.episode
val videoId = if (season != null && episode != null) {
"${meta.id}:${season}:${episode}"
} else {
video.id
}
onPlay?.invoke(
meta.type,
videoId,
meta.name,
meta.logo,
meta.poster,
meta.background,
season,
episode,
video.title,
video.thumbnail,
)
},
)
Spacer(modifier = Modifier.height(32.dp + nuvioPlatformExtraBottomPadding))
}
}

View file

@ -0,0 +1,439 @@
package com.nuvio.app.features.details.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import co.touchlab.kermit.Logger
import com.nuvio.app.features.details.MetaDetails
import com.nuvio.app.features.details.MetaVideo
private val log = Logger.withTag("SeriesContent")
@Composable
fun DetailSeriesContent(
meta: MetaDetails,
modifier: Modifier = Modifier,
onEpisodeClick: ((MetaVideo) -> Unit)? = null,
) {
val groupedEpisodes = remember(meta.videos) {
log.d { "videos count=${meta.videos.size}, type=${meta.type}" }
val withSeasonOrEp = meta.videos.filter { it.season != null || it.episode != null }
log.d { "videos with season/episode=${withSeasonOrEp.size}" }
if (meta.videos.isNotEmpty() && withSeasonOrEp.isEmpty()) {
log.w { "All videos lack season/episode fields! First: ${meta.videos.first()}" }
}
withSeasonOrEp
.sortedWith(
compareBy<MetaVideo>(
{ it.season ?: Int.MAX_VALUE },
{ it.episode ?: Int.MAX_VALUE },
{ it.released ?: "" },
{ it.title },
),
)
.groupBy { it.season ?: 1 }
}
if (groupedEpisodes.isEmpty()) return
val seasons = groupedEpisodes.keys.sorted()
val defaultSeason = seasons.first()
var selectedSeason by rememberSaveable(meta.id) { mutableStateOf(defaultSeason) }
val currentSeason = selectedSeason.takeIf { it in groupedEpisodes } ?: defaultSeason
val episodes = groupedEpisodes.getValue(currentSeason)
BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
val sizing = seriesContentSizing(maxWidth.value)
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
if (seasons.size > 1) {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = "Seasons",
style = MaterialTheme.typography.titleLarge.copy(
fontSize = sizing.seasonHeaderSize,
fontWeight = FontWeight.SemiBold,
),
color = MaterialTheme.colorScheme.onBackground,
)
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(sizing.seasonChipGap),
) {
seasons.forEach { season ->
val isSelected = season == currentSeason
Box(
modifier = Modifier
.clip(RoundedCornerShape(sizing.seasonChipRadius))
.background(
if (isSelected) {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f)
} else {
Color.Transparent
},
)
.clickable { selectedSeason = season }
.padding(
horizontal = sizing.seasonChipHorizontalPadding,
vertical = sizing.seasonChipVerticalPadding,
),
contentAlignment = Alignment.Center,
) {
Text(
text = season.label(),
style = MaterialTheme.typography.bodyLarge.copy(
fontSize = sizing.seasonChipTextSize,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.SemiBold,
),
color = if (isSelected) {
MaterialTheme.colorScheme.onBackground
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
}
}
}
DetailSectionTitle(
title = currentSeason.label(),
)
Column(
verticalArrangement = Arrangement.spacedBy(sizing.cardGap),
) {
episodes.forEach { episode ->
EpisodeCard(
video = episode,
fallbackImage = meta.background ?: meta.poster,
sizing = sizing,
onClick = { onEpisodeClick?.invoke(episode) },
)
}
}
}
}
}
@Composable
private fun EpisodeCard(
video: MetaVideo,
fallbackImage: String?,
sizing: SeriesContentSizing,
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
) {
val cardShape = RoundedCornerShape(sizing.cardRadius)
Row(
modifier = modifier
.fillMaxWidth()
.height(sizing.cardHeight)
.clip(cardShape)
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f))
.border(
width = 1.dp,
color = Color.White.copy(alpha = 0.1f),
shape = cardShape,
)
.clickable(enabled = onClick != null) { onClick?.invoke() },
) {
// Image area - fixed width matching card height per spec
Box(
modifier = Modifier
.width(sizing.imageWidth)
.fillMaxHeight()
.clip(RoundedCornerShape(topStart = sizing.cardRadius, bottomStart = sizing.cardRadius)),
) {
val imageUrl = video.thumbnail ?: fallbackImage
if (imageUrl != null) {
AsyncImage(
model = imageUrl,
contentDescription = video.title,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
} else {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
)
}
// Episode number badge — bottom-right of image per spec
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(bottom = 8.dp, end = 4.dp)
.clip(RoundedCornerShape(sizing.badgeRadius))
.background(Color.Black.copy(alpha = 0.85f))
.border(
width = 1.dp,
color = Color.White.copy(alpha = 0.2f),
shape = RoundedCornerShape(sizing.badgeRadius),
)
.padding(
horizontal = sizing.badgeHorizontalPadding,
vertical = sizing.badgeVerticalPadding,
),
) {
Text(
text = video.episodeBadge(),
style = MaterialTheme.typography.labelMedium.copy(
fontSize = sizing.badgeTextSize,
fontWeight = FontWeight.SemiBold,
letterSpacing = 0.3.sp,
),
color = Color.White,
)
}
}
// Info block
Column(
modifier = Modifier
.fillMaxHeight()
.weight(1f)
.padding(
start = sizing.contentHorizontalPadding,
end = sizing.contentHorizontalPadding,
top = sizing.contentVerticalPadding,
bottom = sizing.contentVerticalPadding,
),
verticalArrangement = Arrangement.spacedBy(sizing.contentSpacing),
) {
Text(
text = video.title,
style = MaterialTheme.typography.titleMedium.copy(
fontSize = sizing.titleTextSize,
fontWeight = FontWeight.Bold,
lineHeight = sizing.titleLineHeight,
letterSpacing = 0.3.sp,
),
color = MaterialTheme.colorScheme.onSurface,
maxLines = sizing.titleMaxLines,
overflow = TextOverflow.Ellipsis,
)
// Metadata row: air date
video.released?.formattedDate()?.let { formattedDate ->
Text(
text = formattedDate,
style = MaterialTheme.typography.labelMedium.copy(
fontSize = sizing.metaTextSize,
fontWeight = FontWeight.Medium,
),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (!video.overview.isNullOrBlank()) {
Text(
text = video.overview,
style = MaterialTheme.typography.bodyMedium.copy(
fontSize = sizing.bodyTextSize,
lineHeight = sizing.bodyLineHeight,
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = sizing.overviewMaxLines,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
private data class SeriesContentSizing(
val seasonHeaderSize: androidx.compose.ui.unit.TextUnit,
val seasonChipGap: Dp,
val seasonChipRadius: Dp,
val seasonChipHorizontalPadding: Dp,
val seasonChipVerticalPadding: Dp,
val seasonChipTextSize: androidx.compose.ui.unit.TextUnit,
val cardHeight: Dp,
val imageWidth: Dp,
val cardRadius: Dp,
val cardGap: Dp,
val contentHorizontalPadding: Dp,
val contentVerticalPadding: Dp,
val contentSpacing: Dp,
val titleTextSize: androidx.compose.ui.unit.TextUnit,
val titleLineHeight: androidx.compose.ui.unit.TextUnit,
val titleMaxLines: Int,
val bodyTextSize: androidx.compose.ui.unit.TextUnit,
val bodyLineHeight: androidx.compose.ui.unit.TextUnit,
val overviewMaxLines: Int,
val metaTextSize: androidx.compose.ui.unit.TextUnit,
val badgeTextSize: androidx.compose.ui.unit.TextUnit,
val badgeRadius: Dp,
val badgeHorizontalPadding: Dp,
val badgeVerticalPadding: Dp,
)
private fun seriesContentSizing(maxWidthDp: Float): SeriesContentSizing =
when {
maxWidthDp >= 1440f -> SeriesContentSizing(
seasonHeaderSize = 28.sp,
seasonChipGap = 20.dp,
seasonChipRadius = 16.dp,
seasonChipHorizontalPadding = 20.dp,
seasonChipVerticalPadding = 16.dp,
seasonChipTextSize = 16.sp,
cardHeight = 200.dp,
imageWidth = 200.dp,
cardRadius = 20.dp,
cardGap = 20.dp,
contentHorizontalPadding = 20.dp,
contentVerticalPadding = 18.dp,
contentSpacing = 8.dp,
titleTextSize = 18.sp,
titleLineHeight = 24.sp,
titleMaxLines = 3,
bodyTextSize = 15.sp,
bodyLineHeight = 22.sp,
overviewMaxLines = 4,
metaTextSize = 13.sp,
badgeTextSize = 13.sp,
badgeRadius = 6.dp,
badgeHorizontalPadding = 8.dp,
badgeVerticalPadding = 4.dp,
)
maxWidthDp >= 1024f -> SeriesContentSizing(
seasonHeaderSize = 26.sp,
seasonChipGap = 18.dp,
seasonChipRadius = 14.dp,
seasonChipHorizontalPadding = 18.dp,
seasonChipVerticalPadding = 14.dp,
seasonChipTextSize = 15.sp,
cardHeight = 180.dp,
imageWidth = 180.dp,
cardRadius = 18.dp,
cardGap = 18.dp,
contentHorizontalPadding = 18.dp,
contentVerticalPadding = 16.dp,
contentSpacing = 8.dp,
titleTextSize = 17.sp,
titleLineHeight = 22.sp,
titleMaxLines = 3,
bodyTextSize = 14.sp,
bodyLineHeight = 20.sp,
overviewMaxLines = 4,
metaTextSize = 12.sp,
badgeTextSize = 12.sp,
badgeRadius = 5.dp,
badgeHorizontalPadding = 7.dp,
badgeVerticalPadding = 3.dp,
)
maxWidthDp >= 768f -> SeriesContentSizing(
seasonHeaderSize = 24.sp,
seasonChipGap = 16.dp,
seasonChipRadius = 12.dp,
seasonChipHorizontalPadding = 16.dp,
seasonChipVerticalPadding = 12.dp,
seasonChipTextSize = 17.sp,
cardHeight = 160.dp,
imageWidth = 160.dp,
cardRadius = 16.dp,
cardGap = 16.dp,
contentHorizontalPadding = 16.dp,
contentVerticalPadding = 14.dp,
contentSpacing = 6.dp,
titleTextSize = 16.sp,
titleLineHeight = 20.sp,
titleMaxLines = 3,
bodyTextSize = 14.sp,
bodyLineHeight = 20.sp,
overviewMaxLines = 3,
metaTextSize = 12.sp,
badgeTextSize = 11.sp,
badgeRadius = 4.dp,
badgeHorizontalPadding = 6.dp,
badgeVerticalPadding = 2.dp,
)
else -> SeriesContentSizing(
seasonHeaderSize = 18.sp,
seasonChipGap = 16.dp,
seasonChipRadius = 12.dp,
seasonChipHorizontalPadding = 16.dp,
seasonChipVerticalPadding = 12.dp,
seasonChipTextSize = 15.sp,
cardHeight = 120.dp,
imageWidth = 120.dp,
cardRadius = 16.dp,
cardGap = 16.dp,
contentHorizontalPadding = 12.dp,
contentVerticalPadding = 12.dp,
contentSpacing = 4.dp,
titleTextSize = 15.sp,
titleLineHeight = 18.sp,
titleMaxLines = 2,
bodyTextSize = 13.sp,
bodyLineHeight = 18.sp,
overviewMaxLines = 2,
metaTextSize = 12.sp,
badgeTextSize = 11.sp,
badgeRadius = 4.dp,
badgeHorizontalPadding = 6.dp,
badgeVerticalPadding = 2.dp,
)
}
private fun Int.label(): String =
if (this <= 0) {
"Specials"
} else {
"Season $this"
}
private fun MetaVideo.episodeBadge(): String =
episode?.let { "E${it.toString().padStart(2, '0')}" } ?: "EP"
private fun String.formattedDate(): String {
val isoDate = substringBefore('T')
return if (isoDate.length == 10) isoDate else this
}

View file

@ -82,12 +82,12 @@ object HomeRepository {
catalogId = catalogId,
)
val payload = httpGetText(catalogUrl)
val items = HomeCatalogParser.parseCatalog(payload).take(12)
val items = HomeCatalogParser.parseCatalog(payload)
require(items.isNotEmpty()) { "No feed items returned for $catalogName." }
return HomeCatalogSection(
key = "${manifest.id}:$type:$catalogId",
title = catalogName,
title = "$catalogName - ${type.displayLabel()}",
subtitle = manifest.name,
addonName = manifest.name,
type = type,
@ -108,3 +108,8 @@ private fun buildCatalogUrl(
.removeSuffix("/manifest.json")
return "$baseUrl/catalog/$type/$catalogId.json"
}
private fun String.displayLabel(): String =
replaceFirstChar { char ->
if (char.isLowerCase()) char.titlecase() else char.toString()
}

View file

@ -2,11 +2,13 @@ package com.nuvio.app.features.home
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.ui.NuvioScreen
import com.nuvio.app.features.addons.AddonRepository
@ -77,6 +79,7 @@ fun HomeScreen(
) { index ->
HomeCatalogRowSection(
section = homeUiState.sections[index],
modifier = Modifier.padding(bottom = 12.dp),
onPosterClick = onPosterClick,
)
}

View file

@ -0,0 +1,54 @@
package com.nuvio.app.features.streams
data class StreamItem(
val name: String? = null,
val description: String? = null,
val url: String? = null,
val infoHash: String? = null,
val fileIdx: Int? = null,
val externalUrl: String? = null,
val addonName: String,
val addonId: String,
val behaviorHints: StreamBehaviorHints = StreamBehaviorHints(),
) {
val streamLabel: String
get() = name ?: "Stream"
val streamSubtitle: String?
get() = description
val hasPlayableSource: Boolean
get() = url != null || infoHash != null || externalUrl != null
}
data class StreamBehaviorHints(
val bingeGroup: String? = null,
val notWebReady: Boolean = false,
val videoSize: Long? = null,
val filename: String? = null,
)
data class AddonStreamGroup(
val addonName: String,
val addonId: String,
val streams: List<StreamItem>,
val isLoading: Boolean = false,
val error: String? = null,
)
data class StreamsUiState(
val groups: List<AddonStreamGroup> = emptyList(),
val activeAddonIds: Set<String> = emptySet(),
val selectedFilter: String? = null,
val isAnyLoading: Boolean = false,
) {
val filteredGroups: List<AddonStreamGroup>
get() = if (selectedFilter == null) groups
else groups.filter { it.addonId == selectedFilter }
val allStreams: List<StreamItem>
get() = filteredGroups.flatMap { it.streams }
val hasAnyStreams: Boolean
get() = groups.any { it.streams.isNotEmpty() }
}

View file

@ -0,0 +1,63 @@
package com.nuvio.app.features.streams
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
internal object StreamParser {
private val json = Json { ignoreUnknownKeys = true }
fun parse(
payload: String,
addonName: String,
addonId: String,
): List<StreamItem> {
val root = json.parseToJsonElement(payload).jsonObject
val streamsArray = root["streams"] as? JsonArray ?: return emptyList()
return streamsArray.mapNotNull { element ->
val obj = element as? JsonObject ?: return@mapNotNull null
val url = obj.string("url")
val infoHash = obj.string("infoHash")
val externalUrl = obj.string("externalUrl")
// Must have at least one playable source
if (url == null && infoHash == null && externalUrl == null) return@mapNotNull null
val hintsObj = obj["behaviorHints"] as? JsonObject
StreamItem(
name = obj.string("name"),
description = obj.string("description") ?: obj.string("title"),
url = url,
infoHash = infoHash,
fileIdx = obj.int("fileIdx"),
externalUrl = externalUrl,
addonName = addonName,
addonId = addonId,
behaviorHints = StreamBehaviorHints(
bingeGroup = hintsObj?.string("bingeGroup"),
notWebReady = hintsObj?.boolean("notWebReady") ?: false,
videoSize = hintsObj?.long("videoSize"),
filename = hintsObj?.string("filename"),
),
)
}
}
private fun JsonObject.string(name: String): String? =
this[name]?.jsonPrimitive?.contentOrNull
private fun JsonObject.int(name: String): Int? =
this[name]?.jsonPrimitive?.intOrNull
private fun JsonObject.long(name: String): Long? =
this[name]?.jsonPrimitive?.longOrNull
private fun JsonObject.boolean(name: String): Boolean? =
this[name]?.jsonPrimitive?.booleanOrNull
}

View file

@ -0,0 +1,135 @@
package com.nuvio.app.features.streams
import co.touchlab.kermit.Logger
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.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
object StreamsRepository {
private val log = Logger.withTag("StreamsRepo")
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _uiState = MutableStateFlow(StreamsUiState())
val uiState: StateFlow<StreamsUiState> = _uiState.asStateFlow()
private var activeJob: Job? = null
/**
* Loads streams for a given type + videoId from all installed addons that declare
* the "stream" resource for the given type (and matching idPrefixes if set).
*
* For movies: videoId == meta id (e.g. "tt1234567")
* For series: videoId == "{metaId}:{season}:{episode}" (e.g. "tt0898266:9:17")
*/
fun load(type: String, videoId: String) {
activeJob?.cancel()
_uiState.value = StreamsUiState()
val streamAddons = AddonRepository.uiState.value.addons
.mapNotNull { it.manifest }
.filter { manifest ->
manifest.resources.any { resource ->
resource.name == "stream" &&
resource.types.contains(type) &&
(resource.idPrefixes.isEmpty() ||
resource.idPrefixes.any { videoId.startsWith(it) })
}
}
log.d { "Found ${streamAddons.size} addons for stream type=$type id=$videoId" }
if (streamAddons.isEmpty()) {
_uiState.value = StreamsUiState(isAnyLoading = false)
return
}
// Initialise loading placeholders
val initialGroups = streamAddons.map { manifest ->
AddonStreamGroup(
addonName = manifest.name,
addonId = manifest.id,
streams = emptyList(),
isLoading = true,
)
}
_uiState.value = StreamsUiState(
groups = initialGroups,
activeAddonIds = streamAddons.map { it.id }.toSet(),
isAnyLoading = true,
)
activeJob = scope.launch {
val jobs = streamAddons.map { manifest ->
async {
val encodedId = videoId.encodeForPath()
val url = "${manifest.transportUrl}/stream/$type/$encodedId.json"
log.d { "Fetching streams from: $url" }
runCatching {
val payload = httpGetText(url)
StreamParser.parse(
payload = payload,
addonName = manifest.name,
addonId = manifest.id,
)
}.fold(
onSuccess = { streams ->
log.d { "Got ${streams.size} streams from ${manifest.name}" }
AddonStreamGroup(
addonName = manifest.name,
addonId = manifest.id,
streams = streams,
isLoading = false,
)
},
onFailure = { err ->
log.w(err) { "Failed to fetch streams from ${manifest.name}" }
AddonStreamGroup(
addonName = manifest.name,
addonId = manifest.id,
streams = emptyList(),
isLoading = false,
error = err.message,
)
},
)
}
}
// Collect results as they arrive and update state incrementally
jobs.forEach { deferred ->
val result = deferred.await()
_uiState.update { current ->
val updated = current.groups.map { group ->
if (group.addonId == result.addonId) result else group
}
current.copy(
groups = updated,
isAnyLoading = updated.any { it.isLoading },
)
}
}
}
}
fun selectFilter(addonId: String?) {
_uiState.update { it.copy(selectedFilter = addonId) }
}
fun clear() {
activeJob?.cancel()
_uiState.value = StreamsUiState()
}
// Encode id segment so colons and slashes don't break URL path parsing on addons
private fun String.encodeForPath(): String =
replace("%", "%25").replace(" ", "%20")
}

View file

@ -0,0 +1,686 @@
package com.nuvio.app.features.streams
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.fillMaxHeight
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.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
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.material.icons.rounded.SearchOff
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.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.style.TextAlign
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 kotlin.math.round
// ---------------------------------------------------------------------------
// Streams Screen
// ---------------------------------------------------------------------------
@Composable
fun StreamsScreen(
type: String,
videoId: String,
title: String,
logo: String? = null,
poster: String? = null,
background: String? = null,
seasonNumber: Int? = null,
episodeNumber: Int? = null,
episodeTitle: String? = null,
episodeThumbnail: String? = null,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
val uiState by StreamsRepository.uiState.collectAsStateWithLifecycle()
val isEpisode = seasonNumber != null && episodeNumber != null
LaunchedEffect(type, videoId) {
StreamsRepository.load(type, videoId)
}
Box(
modifier = modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
) {
// Background artwork
val backdropUrl = background ?: poster
if (backdropUrl != null) {
AsyncImage(
model = backdropUrl,
contentDescription = null,
modifier = Modifier
.fillMaxSize()
.blur(22.dp),
contentScale = ContentScale.Crop,
)
// Dark scrim
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.82f)),
)
}
// Main content column
Column(modifier = Modifier.fillMaxSize()) {
// Hero block
if (isEpisode && seasonNumber != null && episodeNumber != null) {
EpisodeHeroBlock(
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
episodeTitle = episodeTitle ?: title,
thumbnail = episodeThumbnail ?: background ?: poster,
showTitle = title,
)
} else {
MovieHeroBlock(
title = title,
logo = logo,
)
}
// Provider filter chips
ProviderFilterRow(
groups = uiState.groups,
selectedFilter = uiState.selectedFilter,
onFilterSelected = { addonId -> StreamsRepository.selectFilter(addonId) },
)
// Stream list
StreamList(
uiState = uiState,
modifier = Modifier.weight(1f),
)
}
// Back button overlay (top-left)
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.45f),
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),
)
}
}
}
// ---------------------------------------------------------------------------
// Movie Hero
// ---------------------------------------------------------------------------
@Composable
private fun MovieHeroBlock(
title: String,
logo: String?,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.fillMaxWidth()
.height(140.dp)
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top)),
contentAlignment = Alignment.Center,
) {
if (logo != null) {
AsyncImage(
model = logo,
contentDescription = null,
modifier = Modifier
.height(80.dp)
.fillMaxWidth(0.85f),
contentScale = ContentScale.Fit,
)
} else {
Text(
text = title,
style = MaterialTheme.typography.displayLarge.copy(
fontSize = 28.sp,
fontWeight = FontWeight.Black,
letterSpacing = (-0.5).sp,
),
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 20.dp),
)
}
}
}
// ---------------------------------------------------------------------------
// Episode Hero
// ---------------------------------------------------------------------------
@Composable
private fun EpisodeHeroBlock(
seasonNumber: Int,
episodeNumber: Int,
episodeTitle: String,
thumbnail: String?,
showTitle: String,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.fillMaxWidth()
.height(220.dp),
) {
// Thumbnail image
if (thumbnail != null) {
AsyncImage(
model = thumbnail,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
}
// Gradient overlay bottom-up
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.5f),
Color.Black.copy(alpha = 0.9f),
Color.Black.copy(alpha = 0.98f),
),
startY = 0f,
endY = Float.POSITIVE_INFINITY,
),
),
)
// Safe-area push-down for status bar, then content pinned to bottom
Column(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top))
.padding(horizontal = 16.dp)
.padding(bottom = 12.dp),
verticalArrangement = Arrangement.Bottom,
) {
// Episode label
Text(
text = "S${seasonNumber} · E${episodeNumber}",
style = MaterialTheme.typography.labelMedium.copy(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
),
color = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.height(2.dp))
// Episode title
Text(
text = episodeTitle,
style = MaterialTheme.typography.titleLarge.copy(
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
),
color = MaterialTheme.colorScheme.onBackground,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(2.dp))
// Show title
Text(
text = showTitle,
style = MaterialTheme.typography.bodyMedium.copy(
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
// ---------------------------------------------------------------------------
// Provider Filter Row
// ---------------------------------------------------------------------------
@Composable
private fun ProviderFilterRow(
groups: List<AddonStreamGroup>,
selectedFilter: String?,
onFilterSelected: (String?) -> Unit,
modifier: Modifier = Modifier,
) {
val addonGroups = groups.filter { it.streams.isNotEmpty() || it.isLoading }
if (addonGroups.isEmpty()) return
Row(
modifier = modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
// "All" chip
FilterChip(
label = "All",
isSelected = selectedFilter == null,
onClick = { onFilterSelected(null) },
)
addonGroups.forEach { group ->
FilterChip(
label = group.addonName,
isSelected = selectedFilter == group.addonId,
onClick = { onFilterSelected(group.addonId) },
)
}
}
}
@Composable
private fun FilterChip(
label: String,
isSelected: Boolean,
onClick: () -> Unit,
) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(
if (isSelected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
)
.clickable(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 8.dp),
) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium.copy(
fontSize = 14.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.SemiBold,
letterSpacing = 0.1.sp,
),
color = if (isSelected) MaterialTheme.colorScheme.onPrimary
else MaterialTheme.colorScheme.onSurface,
maxLines = 1,
)
}
}
// ---------------------------------------------------------------------------
// Stream List
// ---------------------------------------------------------------------------
@Composable
private fun StreamList(
uiState: StreamsUiState,
modifier: Modifier = Modifier,
) {
val filteredGroups = uiState.filteredGroups
val hasAnyStreams = filteredGroups.any { it.streams.isNotEmpty() }
val allLoading = filteredGroups.all { it.isLoading }
val anyLoading = filteredGroups.any { it.isLoading }
LazyColumn(
modifier = modifier.fillMaxWidth(),
contentPadding = PaddingValues(
horizontal = 12.dp,
vertical = 12.dp,
),
verticalArrangement = Arrangement.spacedBy(0.dp),
) {
when {
allLoading && !hasAnyStreams -> {
item {
LoadingStateBlock()
}
}
!hasAnyStreams && !anyLoading -> {
item {
EmptyStateBlock()
}
}
else -> {
filteredGroups.forEach { group ->
streamSection(group = group, showHeader = uiState.selectedFilter == null)
}
if (anyLoading) {
item {
FooterLoadingBlock()
}
}
item {
Spacer(modifier = Modifier.height(nuvioPlatformExtraBottomPadding + 80.dp))
}
}
}
}
}
private fun LazyListScope.streamSection(
group: AddonStreamGroup,
showHeader: Boolean,
) {
if (group.streams.isEmpty() && !group.isLoading) return
if (showHeader) {
item(key = "header_${group.addonId}") {
StreamSectionHeader(
addonName = group.addonName,
isLoading = group.isLoading,
)
}
}
items(
items = group.streams,
key = { stream -> "${group.addonId}_${stream.url ?: stream.infoHash ?: stream.streamLabel}" },
) { stream ->
StreamCard(stream = stream)
Spacer(modifier = Modifier.height(10.dp))
}
}
// ---------------------------------------------------------------------------
// Stream Section Header
// ---------------------------------------------------------------------------
@Composable
private fun StreamSectionHeader(
addonName: String,
isLoading: Boolean,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = addonName,
style = MaterialTheme.typography.bodyMedium.copy(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
),
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.9f),
)
AnimatedVisibility(visible = isLoading, enter = fadeIn(), exit = fadeOut()) {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(
modifier = Modifier.size(12.dp),
strokeWidth = 1.5.dp,
color = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = "Fetching…",
style = MaterialTheme.typography.labelSmall.copy(fontSize = 12.sp),
color = MaterialTheme.colorScheme.primary,
)
}
}
}
}
// ---------------------------------------------------------------------------
// Stream Card
// ---------------------------------------------------------------------------
@Composable
private fun StreamCard(
stream: StreamItem,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.heightIn(min = 68.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surface)
.padding(14.dp),
verticalAlignment = Alignment.Top,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stream.streamLabel,
style = MaterialTheme.typography.bodyMedium.copy(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
lineHeight = 20.sp,
letterSpacing = 0.1.sp,
),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
val subtitle = stream.streamSubtitle
if (!subtitle.isNullOrBlank()) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall.copy(
fontSize = 12.sp,
lineHeight = 18.sp,
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(modifier = Modifier.height(6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
StreamSourceBadge(stream = stream)
StreamFileSizeBadge(stream = stream)
}
}
}
}
@Composable
private fun StreamSourceBadge(stream: StreamItem) {
val (label, color) = when {
stream.url != null -> "HTTP" to MaterialTheme.colorScheme.primary.copy(alpha = 0.8f)
stream.infoHash != null -> "TORRENT" to Color(0xFF4CAF50).copy(alpha = 0.9f)
stream.externalUrl != null -> "EXTERNAL" to MaterialTheme.colorScheme.secondary.copy(alpha = 0.8f)
else -> return
}
Box(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(color.copy(alpha = 0.15f))
.padding(horizontal = 8.dp, vertical = 3.dp),
) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall.copy(
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 0.2.sp,
),
color = color,
)
}
}
@Composable
private fun StreamFileSizeBadge(stream: StreamItem) {
val bytes = stream.behaviorHints.videoSize ?: return
val gib = bytes.toDouble() / (1024.0 * 1024.0 * 1024.0)
val sizeLabel = if (gib >= 1.0) {
val roundedGiB = round(gib * 10.0) / 10.0
"$roundedGiB GB"
} else {
val mib = bytes.toDouble() / (1024.0 * 1024.0)
"${round(mib).toInt()} MB"
}
Box(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.35f))
.padding(horizontal = 8.dp, vertical = 3.dp),
) {
Text(
text = "SIZE $sizeLabel",
style = MaterialTheme.typography.labelSmall.copy(
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 0.2.sp,
),
color = MaterialTheme.colorScheme.onBackground,
)
}
}
// ---------------------------------------------------------------------------
// State blocks
// ---------------------------------------------------------------------------
@Composable
private fun LoadingStateBlock(modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(vertical = 48.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(32.dp),
)
Text(
text = "Finding streams…",
style = MaterialTheme.typography.bodySmall.copy(
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
),
color = MaterialTheme.colorScheme.primary,
)
}
}
@Composable
private fun EmptyStateBlock(modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = Icons.Rounded.SearchOff,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "No streams found",
style = MaterialTheme.typography.bodyLarge.copy(
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "None of your installed addons returned streams for this title.",
style = MaterialTheme.typography.bodySmall.copy(fontSize = 14.sp),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
textAlign = TextAlign.Center,
)
}
}
@Composable
private fun FooterLoadingBlock(modifier: Modifier = Modifier) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Checking more addons…",
style = MaterialTheme.typography.bodySmall.copy(
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
),
color = MaterialTheme.colorScheme.primary,
)
}
}

View file

@ -6,4 +6,6 @@ class IOSPlatform: Platform {
override val name: String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion
}
actual fun getPlatform(): Platform = IOSPlatform()
actual fun getPlatform(): Platform = IOSPlatform()
internal actual val isIos: Boolean = true

View file

@ -12,6 +12,7 @@ androidx-lifecycle = "2.9.6"
androidx-testExt = "1.3.0"
composeMultiplatform = "1.10.0"
coil = "3.4.0"
kermit = "2.0.5"
junit = "4.13.2"
kotlin = "2.3.0"
kotlinx-serialization = "1.8.1"
@ -41,6 +42,7 @@ coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil"
coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" }
kermit = { module = "co.touchlab:kermit", version.ref = "kermit" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
[plugins]

323
scripts/nuvio_debug_logs.sh Executable file
View file

@ -0,0 +1,323 @@
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────────────────────
# Nuvio Debug Log Viewer
# ─────────────────────────────────────────────────────────────────────────────
# Streams live ADB logcat for the Nuvio debug build, colour-coded by log
# level. Press C to clear all logs on screen. Press Q to quit.
#
# Usage:
# ./scripts/nuvio_debug_logs.sh [options]
#
# Options:
# -s, --serial <id> ADB device serial (optional, for multi-device)
# -t, --tag <regex> Additional logcat tag filter regex (default: all app)
# -c, --clear Clear logcat buffer before streaming
# -h, --help Show help
# ─────────────────────────────────────────────────────────────────────────────
set -uo pipefail
PACKAGE="com.nuvio.app"
SERIAL=""
CLEAR_BUFFER=false
TAG_FILTER=""
STREAM_PID=""
USER_QUIT=false
# ── Log file ────────────────────────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
LOG_DIR="${PROJECT_DIR}/logs"
mkdir -p "$LOG_DIR"
LOG_FILE="${LOG_DIR}/debug_$(date +%Y%m%d_%H%M%S).log"
# ── Noise suppression ───────────────────────────────────────────────────────
NOISE_TAGS='EGL_emulation|OpenGLRenderer|eglCodecCommon|goldfish|gralloc|hwcomposer|SurfaceFlinger|chatty|ConfigStore|libEGL|MediaCodec|AudioTrack|AudioFlinger|BufferQueueProducer|GraphicBufferSource|OMXClient'
# ── ANSI colour codes ───────────────────────────────────────────────────────
RST='\033[0m'
BOLD='\033[1m'
DIM='\033[2m'
CLR_V='\033[36m'
CLR_D='\033[34m'
CLR_I='\033[32m'
CLR_W='\033[33m'
CLR_E='\033[31m'
CLR_F='\033[35m'
CLR_HEADER='\033[38;5;141m'
CLR_ACCENT='\033[38;5;75m'
CLR_META='\033[38;5;245m'
CLR_HOTKEY='\033[38;5;220m'
# ── Helpers ──────────────────────────────────────────────────────────────────
usage() {
cat <<'EOF'
Usage: ./scripts/nuvio_debug_logs.sh [options]
Stream live colour-coded ADB logs for Nuvio debug builds.
Options:
-s, --serial <id> ADB device serial (optional)
-t, --tag <regex> Additional grep regex to filter log tags
-c, --clear Clear logcat buffer before streaming
-h, --help Show this help
Hotkeys (while running):
C Clear all logs currently shown in the terminal
Q Quit the log viewer
Examples:
./scripts/nuvio_debug_logs.sh
./scripts/nuvio_debug_logs.sh --serial emulator-5554
./scripts/nuvio_debug_logs.sh --tag 'MetaDetailsRepo|SeriesContent'
./scripts/nuvio_debug_logs.sh --clear --tag 'Sync|Auth'
EOF
}
# ── Parse arguments ──────────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case "$1" in
-s|--serial) SERIAL="${2:-}"; shift 2 ;;
-t|--tag) TAG_FILTER="${2:-}"; shift 2 ;;
-c|--clear) CLEAR_BUFFER=true; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
esac
done
# ── ADB setup ────────────────────────────────────────────────────────────────
ADB=(adb)
if [[ -n "$SERIAL" ]]; then
ADB+=( -s "$SERIAL" )
fi
if ! "${ADB[@]}" get-state >/dev/null 2>&1; then
echo -e "${CLR_E}✗ No ADB device detected. Connect a device or start an emulator first.${RST}" >&2
"${ADB[@]}" devices 2>/dev/null || true
exit 1
fi
DEVICE_MODEL=$("${ADB[@]}" shell getprop ro.product.model 2>/dev/null | tr -d '\r' || echo "unknown")
ANDROID_VER=$("${ADB[@]}" shell getprop ro.build.version.release 2>/dev/null | tr -d '\r' || echo "?")
if $CLEAR_BUFFER; then
"${ADB[@]}" logcat -c
fi
# ── Resolve app UID ──────────────────────────────────────────────────────────
uid_from_pm() {
"${ADB[@]}" shell cmd package list packages -U "$PACKAGE" 2>/dev/null \
| tr -d '\r' \
| sed -nE 's/.*uid:([0-9]+).*/\1/p' \
| head -n1
}
uid_from_dumpsys() {
"${ADB[@]}" shell dumpsys package "$PACKAGE" 2>/dev/null \
| tr -d '\r' \
| sed -nE 's/.*userId=([0-9]+).*/\1/p' \
| head -n1
}
APP_UID="$(uid_from_pm || true)"
if [[ -z "$APP_UID" ]]; then
APP_UID="$(uid_from_dumpsys || true)"
fi
# ── Colour-coding function ──────────────────────────────────────────────────
colorize_line() {
local line="$1"
local level="${line:0:1}"
local clr=""
case "$level" in
V) clr="$CLR_V" ;;
D) clr="$CLR_D" ;;
I) clr="$CLR_I" ;;
W) clr="$CLR_W" ;;
E) clr="$CLR_E" ;;
F) clr="$CLR_F" ;;
*) clr="$DIM" ;;
esac
local badge=""
case "$level" in
V) badge="${CLR_V}${BOLD} VRB ${RST}" ;;
D) badge="${CLR_D}${BOLD} DBG ${RST}" ;;
I) badge="${CLR_I}${BOLD} INF ${RST}" ;;
W) badge="${CLR_W}${BOLD} WRN ${RST}" ;;
E) badge="${CLR_E}${BOLD} ERR ${RST}" ;;
F) badge="${CLR_F}${BOLD} FTL ${RST}" ;;
*) badge="${DIM} ??? ${RST}" ;;
esac
echo -e "${badge} ${clr}${line}${RST}"
}
# ── Stream logcat with colour ───────────────────────────────────────────────
stream_logcat_colored() {
local mode="$1"
local value="$2"
local noise_re="$NOISE_TAGS"
"${ADB[@]}" logcat -v brief "$mode" "$value" 2>/dev/null \
| while IFS= read -r raw_line; do
if [[ "$raw_line" =~ $noise_re ]]; then
continue
fi
if [[ -n "$TAG_FILTER" ]]; then
if [[ ! "$raw_line" =~ $TAG_FILTER ]]; then
continue
fi
fi
echo "$raw_line" >> "$LOG_FILE"
colorize_line "$raw_line"
done
}
# ── Process management ───────────────────────────────────────────────────────
stop_stream() {
if [[ -n "$STREAM_PID" ]] && kill -0 "$STREAM_PID" 2>/dev/null; then
kill -- -"$STREAM_PID" 2>/dev/null || kill "$STREAM_PID" 2>/dev/null || true
wait "$STREAM_PID" 2>/dev/null || true
fi
STREAM_PID=""
}
start_stream() {
local mode="$1"
local value="$2"
set -m
stream_logcat_colored "$mode" "$value" &
STREAM_PID=$!
set +m
}
clear_terminal() {
if [[ -t 1 ]]; then
printf '\033[2J\033[3J\033[H'
print_banner
fi
}
# ── Pretty banner ───────────────────────────────────────────────────────────
print_banner() {
echo -e ""
echo -e "${CLR_HEADER}${BOLD} ╔══════════════════════════════════════════════════════╗${RST}"
echo -e "${CLR_HEADER}${BOLD} ║ 📺 Nuvio Debug Log Viewer ║${RST}"
echo -e "${CLR_HEADER}${BOLD} ╚══════════════════════════════════════════════════════╝${RST}"
echo -e ""
echo -e " ${CLR_META}Device:${RST} ${CLR_ACCENT}${DEVICE_MODEL}${RST} ${CLR_META}(Android ${ANDROID_VER})${RST}"
echo -e " ${CLR_META}Package:${RST} ${CLR_ACCENT}${PACKAGE}${RST}"
echo -e " ${CLR_META}Log file:${RST} ${CLR_ACCENT}${LOG_FILE}${RST}"
if [[ -n "$TAG_FILTER" ]]; then
echo -e " ${CLR_META}Tag filter:${RST} ${CLR_ACCENT}${TAG_FILTER}${RST}"
fi
echo -e ""
echo -e " ${CLR_META}Log levels:${RST} ${CLR_V}█ VRB${RST} ${CLR_D}█ DBG${RST} ${CLR_I}█ INF${RST} ${CLR_W}█ WRN${RST} ${CLR_E}█ ERR${RST} ${CLR_F}█ FTL${RST}"
echo -e ""
echo -e " ${CLR_HOTKEY}[C]${RST} ${CLR_META}Clear logs${RST} ${CLR_HOTKEY}[Q]${RST} ${CLR_META}Quit${RST} ${CLR_META}Ctrl+C to force stop${RST}"
echo -e "${CLR_META} ──────────────────────────────────────────────────────${RST}"
echo -e ""
}
# ── Handle hotkeys in the run loop ──────────────────────────────────────────
run_with_hotkeys() {
local mode="$1"
local value="$2"
start_stream "$mode" "$value"
if [[ ! -t 0 ]]; then
wait "$STREAM_PID" 2>/dev/null || true
return 0
fi
while true; do
if [[ -z "$STREAM_PID" ]] || ! kill -0 "$STREAM_PID" 2>/dev/null; then
wait "$STREAM_PID" 2>/dev/null || true
STREAM_PID=""
return 0
fi
local key=""
if read -r -s -n1 -t 1 key; then
case "$key" in
c|C)
"${ADB[@]}" logcat -c >/dev/null 2>&1 || true
: > "$LOG_FILE"
clear_terminal
;;
q|Q)
USER_QUIT=true
stop_stream
echo -e "\n${CLR_META}${BOLD} ✓ Log viewer stopped.${RST}"
echo -e " ${CLR_META}Full log saved:${RST} ${CLR_ACCENT}${LOG_FILE}${RST}\n"
return 0
;;
esac
fi
done
}
# ── Cleanup on exit ─────────────────────────────────────────────────────────
cleanup() {
stop_stream
jobs -p 2>/dev/null | xargs -r kill 2>/dev/null || true
stty sane 2>/dev/null || true
echo "" 2>/dev/null || true
}
trap cleanup EXIT INT TERM
# ── Main ─────────────────────────────────────────────────────────────────────
print_banner
if [[ -n "$APP_UID" ]]; then
echo -e " ${CLR_META}Mode:${RST} ${CLR_ACCENT}UID filter${RST} ${CLR_META}(uid=${APP_UID})${RST}"
echo -e ""
run_with_hotkeys --uid "$APP_UID"
rc=$?
if $USER_QUIT; then
exit 0
fi
if [[ $rc -eq 0 ]]; then
exit 0
fi
echo -e "${CLR_W} ⚠ UID filter not supported on this device. Falling back to PID mode...${RST}"
fi
echo -e " ${CLR_META}Mode:${RST} ${CLR_ACCENT}PID tracking${RST}"
echo -e ""
last_pid=""
while true; do
pid="$("${ADB[@]}" shell pidof -s "$PACKAGE" 2>/dev/null | tr -d '\r' || true)"
if [[ -z "$pid" ]]; then
if [[ -n "$last_pid" ]]; then
echo -e "${CLR_W} ⏸ Process stopped. Waiting for ${PACKAGE} to start...${RST}"
last_pid=""
fi
sleep 1
continue
fi
if [[ "$pid" != "$last_pid" ]]; then
echo -e "${CLR_I}${BOLD} ▶ Attached to PID ${pid}${RST}"
last_pid="$pid"
fi
run_with_hotkeys --pid "$pid"
rc=$?
if $USER_QUIT; then
exit 0
fi
if [[ $rc -ne 0 ]]; then
sleep 1
fi
done