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
|
!*.xcworkspace/contents.xcworkspacedata
|
||||||
**/xcshareddata/WorkspaceSettings.xcsettings
|
**/xcshareddata/WorkspaceSettings.xcsettings
|
||||||
node_modules/
|
node_modules/
|
||||||
|
logs/
|
||||||
Docs
|
Docs
|
||||||
|
|
@ -46,6 +46,7 @@ kotlin {
|
||||||
implementation(libs.androidx.lifecycle.runtimeCompose)
|
implementation(libs.androidx.lifecycle.runtimeCompose)
|
||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
implementation(libs.androidx.navigation.compose)
|
implementation(libs.androidx.navigation.compose)
|
||||||
|
implementation(libs.kermit)
|
||||||
}
|
}
|
||||||
iosMain.dependencies {
|
iosMain.dependencies {
|
||||||
implementation(libs.ktor.client.darwin)
|
implementation(libs.ktor.client.darwin)
|
||||||
|
|
|
||||||
|
|
@ -7,3 +7,5 @@ class AndroidPlatform : Platform {
|
||||||
}
|
}
|
||||||
|
|
||||||
actual fun getPlatform(): Platform = AndroidPlatform()
|
actual fun getPlatform(): Platform = AndroidPlatform()
|
||||||
|
|
||||||
|
internal actual val isIos: Boolean = false
|
||||||
|
|
@ -1,22 +1,30 @@
|
||||||
package com.nuvio.app
|
package com.nuvio.app
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
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.padding
|
||||||
|
import androidx.compose.foundation.layout.safeDrawing
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.rounded.Extension
|
import androidx.compose.material.icons.rounded.Extension
|
||||||
import androidx.compose.material.icons.rounded.Home
|
import androidx.compose.material.icons.rounded.Home
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.NavigationBar
|
import androidx.compose.material3.NavigationBar
|
||||||
import androidx.compose.material3.NavigationBarItem
|
import androidx.compose.material3.NavigationBarItem
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
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.details.MetaDetailsScreen
|
||||||
import com.nuvio.app.features.home.HomeScreen
|
import com.nuvio.app.features.home.HomeScreen
|
||||||
import com.nuvio.app.features.home.MetaPreview
|
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
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|
@ -40,6 +50,20 @@ object TabsRoute
|
||||||
@Serializable
|
@Serializable
|
||||||
data class DetailRoute(val type: String, val id: String)
|
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 {
|
enum class AppScreenTab {
|
||||||
Home,
|
Home,
|
||||||
Addons,
|
Addons,
|
||||||
|
|
@ -60,6 +84,7 @@ fun AppScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@Preview
|
@Preview
|
||||||
fun App() {
|
fun App() {
|
||||||
|
|
@ -73,6 +98,31 @@ fun App() {
|
||||||
val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route
|
val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route
|
||||||
var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) }
|
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(
|
Scaffold(
|
||||||
containerColor = MaterialTheme.colorScheme.background,
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
contentWindowInsets = WindowInsets(0),
|
contentWindowInsets = WindowInsets(0),
|
||||||
|
|
@ -80,6 +130,7 @@ fun App() {
|
||||||
if (currentRoute == TabsRoute::class.qualifiedName) {
|
if (currentRoute == TabsRoute::class.qualifiedName) {
|
||||||
NavigationBar(
|
NavigationBar(
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
windowInsets = WindowInsets(0),
|
||||||
) {
|
) {
|
||||||
NavigationBarItem(
|
NavigationBarItem(
|
||||||
selected = selectedTab == AppScreenTab.Home,
|
selected = selectedTab == AppScreenTab.Home,
|
||||||
|
|
@ -119,10 +170,64 @@ fun App() {
|
||||||
MetaDetailsRepository.clear()
|
MetaDetailsRepository.clear()
|
||||||
navController.popBackStack()
|
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),
|
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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,3 +5,5 @@ interface Platform {
|
||||||
}
|
}
|
||||||
|
|
||||||
expect fun getPlatform(): Platform
|
expect fun getPlatform(): Platform
|
||||||
|
|
||||||
|
internal expect val isIos: Boolean
|
||||||
|
|
@ -61,14 +61,12 @@ object AddonRepository {
|
||||||
|
|
||||||
_uiState.update { current ->
|
_uiState.update { current ->
|
||||||
current.copy(
|
current.copy(
|
||||||
addons = listOf(
|
addons = current.addons + ManagedAddon(
|
||||||
ManagedAddon(
|
|
||||||
manifestUrl = manifestUrl,
|
manifestUrl = manifestUrl,
|
||||||
manifest = manifest,
|
manifest = manifest,
|
||||||
isRefreshing = false,
|
isRefreshing = false,
|
||||||
errorMessage = null,
|
errorMessage = null,
|
||||||
),
|
),
|
||||||
) + current.addons,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
persist()
|
persist()
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ data class MetaDetails(
|
||||||
val language: String? = null,
|
val language: String? = null,
|
||||||
val website: String? = null,
|
val website: String? = null,
|
||||||
val links: List<MetaLink> = emptyList(),
|
val links: List<MetaLink> = emptyList(),
|
||||||
|
val videos: List<MetaVideo> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
data class MetaLink(
|
data class MetaLink(
|
||||||
|
|
@ -27,6 +28,16 @@ data class MetaLink(
|
||||||
val url: String,
|
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(
|
data class MetaDetailsUiState(
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val meta: MetaDetails? = null,
|
val meta: MetaDetails? = null,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonArray
|
import kotlinx.serialization.json.JsonArray
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
import kotlinx.serialization.json.contentOrNull
|
import kotlinx.serialization.json.contentOrNull
|
||||||
|
import kotlinx.serialization.json.intOrNull
|
||||||
import kotlinx.serialization.json.jsonObject
|
import kotlinx.serialization.json.jsonObject
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
|
||||||
|
|
@ -33,6 +34,7 @@ internal object MetaDetailsParser {
|
||||||
language = meta.string("language"),
|
language = meta.string("language"),
|
||||||
website = meta.string("website"),
|
website = meta.string("website"),
|
||||||
links = meta.links(),
|
links = meta.links(),
|
||||||
|
videos = meta.videos(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,4 +58,23 @@ internal object MetaDetailsParser {
|
||||||
val url = link.string("url") ?: return@mapNotNull null
|
val url = link.string("url") ?: return@mapNotNull null
|
||||||
MetaLink(name = linkName, category = category, url = url)
|
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
|
package com.nuvio.app.features.details
|
||||||
|
|
||||||
|
import co.touchlab.kermit.Logger
|
||||||
import com.nuvio.app.features.addons.AddonManifest
|
import com.nuvio.app.features.addons.AddonManifest
|
||||||
import com.nuvio.app.features.addons.AddonRepository
|
import com.nuvio.app.features.addons.AddonRepository
|
||||||
import com.nuvio.app.features.addons.httpGetText
|
import com.nuvio.app.features.addons.httpGetText
|
||||||
|
|
@ -12,11 +13,13 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
object MetaDetailsRepository {
|
object MetaDetailsRepository {
|
||||||
|
private val log = Logger.withTag("MetaDetailsRepo")
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
private val _uiState = MutableStateFlow(MetaDetailsUiState())
|
private val _uiState = MutableStateFlow(MetaDetailsUiState())
|
||||||
val uiState: StateFlow<MetaDetailsUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<MetaDetailsUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
fun load(type: String, id: String) {
|
fun load(type: String, id: String) {
|
||||||
|
log.d { "load() called — type=$type id=$id" }
|
||||||
_uiState.value = MetaDetailsUiState(isLoading = true)
|
_uiState.value = MetaDetailsUiState(isLoading = true)
|
||||||
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
|
@ -31,6 +34,7 @@ object MetaDetailsRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (manifests.isEmpty()) {
|
if (manifests.isEmpty()) {
|
||||||
|
log.w { "No addon provides meta for type=$type id=$id" }
|
||||||
_uiState.value = MetaDetailsUiState(
|
_uiState.value = MetaDetailsUiState(
|
||||||
errorMessage = "No addon provides meta for this content.",
|
errorMessage = "No addon provides meta for this content.",
|
||||||
)
|
)
|
||||||
|
|
@ -65,9 +69,18 @@ object MetaDetailsRepository {
|
||||||
.substringBefore("?")
|
.substringBefore("?")
|
||||||
.removeSuffix("/manifest.json")
|
.removeSuffix("/manifest.json")
|
||||||
val url = "$baseUrl/meta/$type/$id.json"
|
val url = "$baseUrl/meta/$type/$id.json"
|
||||||
|
log.d { "Fetching meta from: $url" }
|
||||||
val payload = httpGetText(url)
|
val payload = httpGetText(url)
|
||||||
MetaDetailsParser.parse(payload)
|
log.d { "Raw payload length=${payload.length}, first 500 chars: ${payload.take(500)}" }
|
||||||
} catch (_: Throwable) {
|
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
|
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.DetailCastSection
|
||||||
import com.nuvio.app.features.details.components.DetailHero
|
import com.nuvio.app.features.details.components.DetailHero
|
||||||
import com.nuvio.app.features.details.components.DetailMetaInfo
|
import com.nuvio.app.features.details.components.DetailMetaInfo
|
||||||
|
import com.nuvio.app.features.details.components.DetailSeriesContent
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MetaDetailsScreen(
|
fun MetaDetailsScreen(
|
||||||
type: String,
|
type: String,
|
||||||
id: String,
|
id: String,
|
||||||
onBack: () -> Unit,
|
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,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val uiState by MetaDetailsRepository.uiState.collectAsStateWithLifecycle()
|
val uiState by MetaDetailsRepository.uiState.collectAsStateWithLifecycle()
|
||||||
|
|
@ -102,12 +104,52 @@ fun MetaDetailsScreen(
|
||||||
.padding(horizontal = 18.dp),
|
.padding(horizontal = 18.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(20.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)
|
DetailMetaInfo(meta = meta)
|
||||||
|
|
||||||
DetailCastSection(cast = meta.cast)
|
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))
|
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,
|
catalogId = catalogId,
|
||||||
)
|
)
|
||||||
val payload = httpGetText(catalogUrl)
|
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." }
|
require(items.isNotEmpty()) { "No feed items returned for $catalogName." }
|
||||||
|
|
||||||
return HomeCatalogSection(
|
return HomeCatalogSection(
|
||||||
key = "${manifest.id}:$type:$catalogId",
|
key = "${manifest.id}:$type:$catalogId",
|
||||||
title = catalogName,
|
title = "$catalogName - ${type.displayLabel()}",
|
||||||
subtitle = manifest.name,
|
subtitle = manifest.name,
|
||||||
addonName = manifest.name,
|
addonName = manifest.name,
|
||||||
type = type,
|
type = type,
|
||||||
|
|
@ -108,3 +108,8 @@ private fun buildCatalogUrl(
|
||||||
.removeSuffix("/manifest.json")
|
.removeSuffix("/manifest.json")
|
||||||
return "$baseUrl/catalog/$type/$catalogId.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.Icons
|
||||||
import androidx.compose.material.icons.rounded.Refresh
|
import androidx.compose.material.icons.rounded.Refresh
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.nuvio.app.core.ui.NuvioScreen
|
import com.nuvio.app.core.ui.NuvioScreen
|
||||||
import com.nuvio.app.features.addons.AddonRepository
|
import com.nuvio.app.features.addons.AddonRepository
|
||||||
|
|
@ -77,6 +79,7 @@ fun HomeScreen(
|
||||||
) { index ->
|
) { index ->
|
||||||
HomeCatalogRowSection(
|
HomeCatalogRowSection(
|
||||||
section = homeUiState.sections[index],
|
section = homeUiState.sections[index],
|
||||||
|
modifier = Modifier.padding(bottom = 12.dp),
|
||||||
onPosterClick = onPosterClick,
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,3 +7,5 @@ class IOSPlatform: Platform {
|
||||||
}
|
}
|
||||||
|
|
||||||
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"
|
androidx-testExt = "1.3.0"
|
||||||
composeMultiplatform = "1.10.0"
|
composeMultiplatform = "1.10.0"
|
||||||
coil = "3.4.0"
|
coil = "3.4.0"
|
||||||
|
kermit = "2.0.5"
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
kotlin = "2.3.0"
|
kotlin = "2.3.0"
|
||||||
kotlinx-serialization = "1.8.1"
|
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" }
|
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" }
|
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" }
|
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" }
|
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
|
||||||
|
|
||||||
[plugins]
|
[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