diff --git a/.gitignore b/.gitignore index 8e1c8856..a0946fe6 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,5 @@ captures !*.xcworkspace/contents.xcworkspacedata **/xcshareddata/WorkspaceSettings.xcsettings node_modules/ +logs/ Docs \ No newline at end of file diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index e24dba21..c8467df7 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -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) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/Platform.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/Platform.android.kt index dbca5b84..5ba9da3c 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/Platform.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/Platform.android.kt @@ -6,4 +6,6 @@ class AndroidPlatform : Platform { override val name: String = "Android ${Build.VERSION.SDK_INT}" } -actual fun getPlatform(): Platform = AndroidPlatform() \ No newline at end of file +actual fun getPlatform(): Platform = AndroidPlatform() + +internal actual val isIos: Boolean = false \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 6affbfaa..e9f00d01 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -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(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 { backStackEntry -> + val route = backStackEntry.toRoute() + 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 + }, + ) + } + } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/Platform.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/Platform.kt index eefa713e..5b1cae86 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/Platform.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/Platform.kt @@ -4,4 +4,6 @@ interface Platform { val name: String } -expect fun getPlatform(): Platform \ No newline at end of file +expect fun getPlatform(): Platform + +internal expect val isIos: Boolean \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonRepository.kt index a0e82ee8..8bc1b12b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonRepository.kt @@ -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() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt index c42273b4..4a6126af 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt @@ -19,6 +19,7 @@ data class MetaDetails( val language: String? = null, val website: String? = null, val links: List = emptyList(), + val videos: List = 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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt index 08497dcc..27dc0259 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt @@ -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 = + 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"), + ) + } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt index 53e50cc9..94b07806 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt @@ -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 = _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 } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index 813eeb4f..1e186d70 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -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)) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt new file mode 100644 index 00000000..5485ac01 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt @@ -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( + { 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 +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt index 8db5fa7a..616ce74b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt @@ -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() + } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index e9606909..8b818828 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -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, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt new file mode 100644 index 00000000..e2f30c2f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt @@ -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, + val isLoading: Boolean = false, + val error: String? = null, +) + +data class StreamsUiState( + val groups: List = emptyList(), + val activeAddonIds: Set = emptySet(), + val selectedFilter: String? = null, + val isAnyLoading: Boolean = false, +) { + val filteredGroups: List + get() = if (selectedFilter == null) groups + else groups.filter { it.addonId == selectedFilter } + + val allStreams: List + get() = filteredGroups.flatMap { it.streams } + + val hasAnyStreams: Boolean + get() = groups.any { it.streams.isNotEmpty() } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt new file mode 100644 index 00000000..5597f93a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt @@ -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 { + 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 +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt new file mode 100644 index 00000000..d49acf7d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt @@ -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 = _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") +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt new file mode 100644 index 00000000..d52a67a8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt @@ -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, + 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, + ) + } +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/Platform.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/Platform.ios.kt index bd9aa1b3..ee348bc5 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/Platform.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/Platform.ios.kt @@ -6,4 +6,6 @@ class IOSPlatform: Platform { override val name: String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion } -actual fun getPlatform(): Platform = IOSPlatform() \ No newline at end of file +actual fun getPlatform(): Platform = IOSPlatform() + +internal actual val isIos: Boolean = true \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c03e90e5..d762241a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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] diff --git a/scripts/nuvio_debug_logs.sh b/scripts/nuvio_debug_logs.sh new file mode 100755 index 00000000..8ae32969 --- /dev/null +++ b/scripts/nuvio_debug_logs.sh @@ -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 ADB device serial (optional, for multi-device) +# -t, --tag 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 ADB device serial (optional) + -t, --tag 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