diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index cd05e1f26..eccfad41d 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -129,6 +129,7 @@ kotlin { commonMain.dependencies { implementation(libs.coil.compose) implementation(libs.coil.network.ktor3) + implementation("dev.chrisbanes.haze:haze:1.7.2") implementation(libs.compose.runtime) implementation(libs.compose.foundation) implementation(libs.compose.material3) 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 index b8cd8b230..76e5dfa66 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt @@ -11,9 +11,10 @@ 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.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides @@ -122,12 +123,94 @@ fun StreamsScreen( background ?: poster } - Box( + BoxWithConstraints( modifier = modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background), ) { - // Background artwork + val isTabletLayout = maxWidth >= 768.dp + + if (isTabletLayout) { + TabletStreamsLayout( + isEpisode = isEpisode, + title = title, + logo = logo, + poster = poster, + background = background, + episodeThumbnail = episodeThumbnail, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + episodeTitle = episodeTitle, + uiState = uiState, + resumePositionMs = effectiveResumePositionMs, + onStreamSelected = onStreamSelected, + ) + } else { + MobileStreamsLayout( + isEpisode = isEpisode, + title = title, + logo = logo, + heroArtwork = heroArtwork, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + episodeTitle = episodeTitle, + uiState = uiState, + resumePositionMs = effectiveResumePositionMs, + onStreamSelected = onStreamSelected, + ) + } + + Row( + modifier = Modifier + .align(Alignment.TopStart) + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top)) + .padding(start = 12.dp, top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + NuvioBackButton( + onClick = onBack, + modifier = Modifier + .size(40.dp), + containerColor = MaterialTheme.colorScheme.background.copy(alpha = 0.45f), + contentColor = MaterialTheme.colorScheme.onBackground, + ) + + Box( + modifier = Modifier + .size(40.dp) + .background( + color = MaterialTheme.colorScheme.background.copy(alpha = 0.45f), + shape = CircleShape, + ) + .clickable(onClick = { StreamsRepository.reload(type, videoId) }), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Rounded.Refresh, + contentDescription = "Refresh streams", + tint = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.size(20.dp), + ) + } + } + } +} + +@Composable +private fun MobileStreamsLayout( + isEpisode: Boolean, + title: String, + logo: String?, + heroArtwork: String?, + seasonNumber: Int?, + episodeNumber: Int?, + episodeTitle: String?, + uiState: StreamsUiState, + resumePositionMs: Long?, + onStreamSelected: (StreamItem) -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier.fillMaxSize()) { if (heroArtwork != null) { AsyncImage( model = heroArtwork, @@ -137,7 +220,6 @@ fun StreamsScreen( .blur(22.dp), contentScale = ContentScale.Crop, ) - // Dark scrim Box( modifier = Modifier .fillMaxSize() @@ -147,10 +229,8 @@ fun StreamsScreen( val streamBlendColor = MaterialTheme.colorScheme.background - // Main content column Column(modifier = Modifier.fillMaxSize()) { - // Hero block - if (isEpisode) { + if (isEpisode && seasonNumber != null && episodeNumber != null) { EpisodeHeroBlock( seasonNumber = seasonNumber, episodeNumber = episodeNumber, @@ -189,9 +269,9 @@ fun StreamsScreen( } Column(modifier = Modifier.fillMaxSize()) { - if (effectiveResumePositionMs != null && effectiveResumePositionMs > 0L) { + if (resumePositionMs != null && resumePositionMs > 0L) { ResumeBanner( - positionMs = effectiveResumePositionMs, + positionMs = resumePositionMs, modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), ) } @@ -209,44 +289,11 @@ fun StreamsScreen( } } } - - Row( - modifier = Modifier - .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top)) - .padding(start = 12.dp, top = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - NuvioBackButton( - onClick = onBack, - modifier = Modifier - .size(40.dp), - containerColor = MaterialTheme.colorScheme.background.copy(alpha = 0.45f), - contentColor = MaterialTheme.colorScheme.onBackground, - ) - - Box( - modifier = Modifier - .size(40.dp) - .background( - color = MaterialTheme.colorScheme.background.copy(alpha = 0.45f), - shape = CircleShape, - ) - .clickable(onClick = { StreamsRepository.reload(type, videoId) }), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Rounded.Refresh, - contentDescription = "Refresh streams", - tint = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.size(20.dp), - ) - } - } } } @Composable -private fun ResumeBanner( +internal fun ResumeBanner( positionMs: Long, modifier: Modifier = Modifier, ) { @@ -415,7 +462,7 @@ private fun EpisodeHeroBlock( // --------------------------------------------------------------------------- @Composable -private fun ProviderFilterRow( +internal fun ProviderFilterRow( groups: List, selectedFilter: String?, onFilterSelected: (String?) -> Unit, @@ -511,7 +558,7 @@ private fun FilterChip( // --------------------------------------------------------------------------- @Composable -private fun StreamList( +internal fun StreamList( uiState: StreamsUiState, onStreamSelected: (StreamItem) -> Unit, modifier: Modifier = Modifier, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt new file mode 100644 index 000000000..9cc321c42 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt @@ -0,0 +1,390 @@ +package com.nuvio.app.features.streams + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +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.Row +import androidx.compose.foundation.layout.Spacer +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.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.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage +import com.nuvio.app.isIos +import dev.chrisbanes.haze.hazeEffect +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState + +@Composable +internal fun TabletStreamsLayout( + isEpisode: Boolean, + title: String, + logo: String?, + poster: String?, + background: String?, + episodeThumbnail: String?, + seasonNumber: Int?, + episodeNumber: Int?, + episodeTitle: String?, + uiState: StreamsUiState, + resumePositionMs: Long?, + onStreamSelected: (StreamItem) -> Unit, + modifier: Modifier = Modifier, +) { + val hazeState = rememberHazeState() + val tabletBackdrop = remember(isEpisode, episodeThumbnail, background, poster) { + resolveTabletBackdrop( + isEpisode = isEpisode, + episodeThumbnail = episodeThumbnail, + background = background, + poster = poster, + ) + } + var backdropVisible by remember(tabletBackdrop) { mutableStateOf(false) } + + LaunchedEffect(tabletBackdrop) { + backdropVisible = tabletBackdrop == null + if (tabletBackdrop != null) { + backdropVisible = true + } + } + + val backdropAlpha by animateFloatAsState( + targetValue = if (backdropVisible) 1f else 0f, + animationSpec = tween(durationMillis = 520), + label = "tablet_backdrop_alpha", + ) + val backdropScale by animateFloatAsState( + targetValue = if (backdropVisible) 1f else 1.05f, + animationSpec = tween(durationMillis = 620), + label = "tablet_backdrop_scale", + ) + + Box(modifier = modifier.fillMaxSize()) { + Box( + modifier = Modifier + .fillMaxSize() + .hazeSource(state = hazeState), + ) { + if (tabletBackdrop != null) { + AsyncImage( + model = tabletBackdrop, + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + alpha = backdropAlpha + scaleX = backdropScale + scaleY = backdropScale + }, + contentScale = ContentScale.Crop, + ) + } else { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + ) + } + + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf( + Color.Black.copy(alpha = 0.2f), + Color.Black.copy(alpha = 0.4f), + Color.Black.copy(alpha = 0.6f), + ), + ), + ), + ) + } + + Row(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .weight(0.4f) + .fillMaxHeight() + .padding(24.dp), + contentAlignment = Alignment.Center, + ) { + if (isEpisode && seasonNumber != null && episodeNumber != null) { + TabletEpisodeInfoPanel( + logo = logo, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + episodeTitle = episodeTitle, + showTitle = title, + ) + } else { + TabletMovieInfoPanel( + title = title, + logo = logo, + ) + } + } + + Box( + modifier = Modifier + .weight(0.6f) + .fillMaxHeight() + .padding( + top = if (isIos) 20.dp else 60.dp, + end = 12.dp, + bottom = 12.dp, + ), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(24.dp)) + .hazeEffect(state = hazeState) + .background(Color.Black.copy(alpha = 0.22f)), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + ) { + if (resumePositionMs != null && resumePositionMs > 0L) { + ResumeBanner( + positionMs = resumePositionMs, + modifier = Modifier.padding(bottom = 8.dp), + ) + } + + ProviderFilterRow( + groups = uiState.groups, + selectedFilter = uiState.selectedFilter, + onFilterSelected = { addonId -> StreamsRepository.selectFilter(addonId) }, + ) + + ActiveScrapersStatusBlock( + groups = uiState.groups, + modifier = Modifier.padding(bottom = 4.dp), + ) + + StreamList( + uiState = uiState, + onStreamSelected = onStreamSelected, + modifier = Modifier.weight(1f), + ) + } + } + } + } + } +} + +@Composable +private fun TabletMovieInfoPanel( + title: String, + logo: String?, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(0.8f), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + if (!logo.isNullOrBlank()) { + AsyncImage( + model = logo, + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .height(120.dp), + contentScale = ContentScale.Fit, + ) + } else if (title.isNotBlank()) { + Text( + text = title, + style = MaterialTheme.typography.displayLarge.copy( + fontSize = 32.sp, + fontWeight = FontWeight.Black, + letterSpacing = (-0.5).sp, + shadow = Shadow( + color = Color.Black.copy(alpha = 0.8f), + offset = Offset(0f, 2f), + blurRadius = 4f, + ), + ), + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } else { + Text( + text = "No metadata available", + style = MaterialTheme.typography.bodyLarge.copy( + fontSize = 16.sp, + fontStyle = FontStyle.Italic, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } +} + +@Composable +private fun TabletEpisodeInfoPanel( + logo: String?, + seasonNumber: Int, + episodeNumber: Int, + episodeTitle: String?, + showTitle: String, + modifier: Modifier = Modifier, +) { + val textShadow = Shadow( + color = Color.Black, + offset = Offset(0f, 0f), + blurRadius = 4f, + ) + + Column( + modifier = modifier.fillMaxWidth(0.8f), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + if (!logo.isNullOrBlank()) { + AsyncImage( + model = logo, + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .height(120.dp), + contentScale = ContentScale.Fit, + ) + } else { + Text( + text = showTitle, + style = MaterialTheme.typography.displayMedium.copy( + fontSize = 28.sp, + fontWeight = FontWeight.Black, + letterSpacing = (-0.5).sp, + shadow = textShadow, + ), + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "S${seasonNumber}E${episodeNumber} - ${episodeTitle?.takeIf { it.isNotBlank() } ?: "Episode"}", + style = MaterialTheme.typography.bodyLarge.copy( + fontSize = 16.sp, + lineHeight = 24.sp, + fontWeight = FontWeight.SemiBold, + shadow = textShadow, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.95f), + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun ActiveScrapersStatusBlock( + groups: List, + modifier: Modifier = Modifier, +) { + val activeScrapers = remember(groups) { + groups.filter { it.isLoading }.map { it.addonName }.distinct() + } + if (activeScrapers.isEmpty()) return + + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Text( + text = "Active scrapers", + style = MaterialTheme.typography.labelSmall.copy( + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), + ) + Spacer(modifier = Modifier.height(6.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + activeScrapers.forEach { addonName -> + Box( + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f)) + .padding(horizontal = 8.dp, vertical = 3.dp), + ) { + Text( + text = addonName, + style = MaterialTheme.typography.labelSmall.copy( + fontSize = 11.sp, + fontWeight = FontWeight.Normal, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + } + } + } +} + +private fun resolveTabletBackdrop( + isEpisode: Boolean, + episodeThumbnail: String?, + background: String?, + poster: String?, +): String? { + if (!isEpisode) return background ?: poster + + val preferredEpisodeThumbnail = episodeThumbnail?.takeIf { + it.isNotBlank() && it != poster + } + return preferredEpisodeThumbnail ?: background ?: poster +} \ No newline at end of file