mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-26 19:12:54 +00:00
streamscreen init
This commit is contained in:
parent
bafae15f7e
commit
9d5d5fa4e2
20 changed files with 1924 additions and 16 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -17,4 +17,5 @@ captures
|
|||
!*.xcworkspace/contents.xcworkspacedata
|
||||
**/xcshareddata/WorkspaceSettings.xcsettings
|
||||
node_modules/
|
||||
logs/
|
||||
Docs
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,4 +4,6 @@ interface Platform {
|
|||
val name: String
|
||||
}
|
||||
|
||||
expect fun getPlatform(): Platform
|
||||
expect fun getPlatform(): Platform
|
||||
|
||||
internal expect val isIos: Boolean
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
323
scripts/nuvio_debug_logs.sh
Executable 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
|
||||
Loading…
Reference in a new issue