diff --git a/app-tv/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreen.kt b/app-tv/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreen.kt index 8c7f76c..9a91c86 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreen.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreen.kt @@ -1,21 +1,41 @@ package hu.bbara.purefin.app.content.episode +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer 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.rememberScrollState -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel +import hu.bbara.purefin.common.ui.MediaCastRow +import hu.bbara.purefin.common.ui.MediaMetaChip +import hu.bbara.purefin.common.ui.MediaSynopsis import hu.bbara.purefin.common.ui.PurefinWaitingScreen +import hu.bbara.purefin.common.ui.components.MediaActionButton import hu.bbara.purefin.common.ui.components.MediaHero +import hu.bbara.purefin.common.ui.components.MediaPlaybackSettings +import hu.bbara.purefin.common.ui.components.MediaResumeButton import hu.bbara.purefin.core.data.navigation.EpisodeDto import hu.bbara.purefin.core.model.Episode import hu.bbara.purefin.feature.shared.content.episode.EpisodeScreenViewModel @@ -26,7 +46,6 @@ fun EpisodeScreen( viewModel: EpisodeScreenViewModel = hiltViewModel(), modifier: Modifier = Modifier ) { - LaunchedEffect(episode) { viewModel.selectEpisode( seriesId = episode.seriesId, @@ -50,6 +69,7 @@ fun EpisodeScreen( ) } +@OptIn(ExperimentalLayoutApi::class) @Composable private fun EpisodeScreenInternal( episode: Episode, @@ -57,36 +77,116 @@ private fun EpisodeScreenInternal( onPlay: () -> Unit, modifier: Modifier = Modifier, ) { + val scheme = MaterialTheme.colorScheme + val hPad = Modifier.padding(horizontal = 16.dp) - Scaffold( - modifier = modifier, - containerColor = MaterialTheme.colorScheme.background, - topBar = { - EpisodeTopBar( - onBack = onBack, - modifier = Modifier + LazyColumn( + modifier = modifier + .fillMaxSize() + .background(scheme.background) + ) { + item { + Box { + MediaHero( + imageUrl = episode.heroImageUrl, + backgroundColor = scheme.background, + heightFraction = 0.30f, + modifier = Modifier.fillMaxWidth() + ) + EpisodeTopBar(onBack = onBack) + } + } + item { + Column(modifier = hPad) { + Text( + text = episode.title, + color = scheme.onBackground, + fontSize = 32.sp, + fontWeight = FontWeight.Bold, + lineHeight = 38.sp + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Episode ${episode.index}", + color = scheme.onBackground, + fontSize = 14.sp, + fontWeight = FontWeight.Medium + ) + Spacer(modifier = Modifier.height(16.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + MediaMetaChip(text = episode.releaseDate) + MediaMetaChip(text = episode.rating) + MediaMetaChip(text = episode.runtime) + MediaMetaChip( + text = episode.format, + background = scheme.primary.copy(alpha = 0.2f), + border = scheme.primary.copy(alpha = 0.3f), + textColor = scheme.primary + ) + } + } + } + item { + Spacer(modifier = Modifier.height(24.dp)) + MediaSynopsis( + synopsis = episode.synopsis, + modifier = hPad ) } - ) { innerPadding -> - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - ) { - MediaHero( - imageUrl = episode.heroImageUrl, - backgroundColor = MaterialTheme.colorScheme.background, - heightFraction = 0.30f, - modifier = Modifier.fillMaxWidth() - ) - EpisodeDetails( - episode = episode, - onPlay = onPlay, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(bottom = innerPadding.calculateBottomPadding()) + item { + Spacer(modifier = Modifier.height(24.dp)) + Row(modifier = hPad) { + MediaResumeButton( + text = if (episode.progress == null) "Play" else "Resume", + progress = episode.progress?.div(100)?.toFloat() ?: 0f, + onClick = onPlay, + modifier = Modifier.sizeIn(maxWidth = 200.dp) + ) + VerticalDivider( + color = scheme.secondary, + thickness = 4.dp, + modifier = Modifier + .height(48.dp) + .padding(horizontal = 16.dp, vertical = 8.dp) + ) + Row { + MediaActionButton( + backgroundColor = scheme.secondary, + iconColor = scheme.onSecondary, + icon = Icons.Outlined.Add, + height = 48.dp + ) + } + } + } + item { + Spacer(modifier = Modifier.height(24.dp)) + MediaPlaybackSettings( + backgroundColor = scheme.surface, + foregroundColor = scheme.onSurface, + audioTrack = "ENG", + subtitles = "ENG", + modifier = hPad ) } + if (episode.cast.isNotEmpty()) { + item { + Column(modifier = hPad) { + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = "Cast", + color = scheme.onBackground, + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(12.dp)) + MediaCastRow(cast = episode.cast) + Spacer(modifier = Modifier.height(16.dp)) + } + } + } } } diff --git a/app-tv/src/main/java/hu/bbara/purefin/app/content/movie/MovieComponents.kt b/app-tv/src/main/java/hu/bbara/purefin/app/content/movie/MovieComponents.kt index 2e0242d..b4b090e 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/app/content/movie/MovieComponents.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/app/content/movie/MovieComponents.kt @@ -33,7 +33,7 @@ import hu.bbara.purefin.common.ui.components.GhostIconButton import hu.bbara.purefin.common.ui.components.MediaActionButton import hu.bbara.purefin.common.ui.components.MediaPlaybackSettings import hu.bbara.purefin.common.ui.components.MediaResumeButton -import hu.bbara.purefin.feature.shared.content.movie.MovieUiModel +import hu.bbara.purefin.core.model.Movie @Composable internal fun MovieTopBar( @@ -64,7 +64,7 @@ internal fun MovieTopBar( @OptIn(ExperimentalLayoutApi::class) @Composable internal fun MovieDetails( - movie: MovieUiModel, + movie: Movie, onPlay: () -> Unit, modifier: Modifier = Modifier ) { @@ -132,16 +132,18 @@ internal fun MovieDetails( subtitles = movie.subtitles ) - Spacer(modifier = Modifier.height(24.dp)) - Text( - text = "Cast", - color = scheme.onBackground, - fontSize = 18.sp, - fontWeight = FontWeight.Bold - ) - Spacer(modifier = Modifier.height(12.dp)) - MediaCastRow( - cast = emptyList() - ) + if (movie.cast.isNotEmpty()) { + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = "Cast", + color = scheme.onBackground, + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(12.dp)) + MediaCastRow( + cast = movie.cast + ) + } } } diff --git a/app-tv/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreen.kt b/app-tv/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreen.kt index feef247..87d4a37 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreen.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreen.kt @@ -1,24 +1,44 @@ package hu.bbara.purefin.app.content.movie +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer 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.rememberScrollState -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel +import hu.bbara.purefin.common.ui.MediaCastRow +import hu.bbara.purefin.common.ui.MediaMetaChip +import hu.bbara.purefin.common.ui.MediaSynopsis import hu.bbara.purefin.common.ui.PurefinWaitingScreen +import hu.bbara.purefin.common.ui.components.MediaActionButton import hu.bbara.purefin.common.ui.components.MediaHero +import hu.bbara.purefin.common.ui.components.MediaPlaybackSettings +import hu.bbara.purefin.common.ui.components.MediaResumeButton import hu.bbara.purefin.core.data.navigation.MovieDto +import hu.bbara.purefin.core.model.Movie import hu.bbara.purefin.feature.shared.content.movie.MovieScreenViewModel -import hu.bbara.purefin.feature.shared.content.movie.MovieUiModel @Composable fun MovieScreen( @@ -42,42 +62,117 @@ fun MovieScreen( } } +@OptIn(ExperimentalLayoutApi::class) @Composable private fun MovieScreenInternal( - movie: MovieUiModel, + movie: Movie, onBack: () -> Unit, onPlay: () -> Unit, modifier: Modifier = Modifier, ) { - Scaffold( - modifier = modifier, - containerColor = MaterialTheme.colorScheme.background, - topBar = { - MovieTopBar( - onBack = onBack, - modifier = Modifier + val scheme = MaterialTheme.colorScheme + val hPad = Modifier.padding(horizontal = 16.dp) + + LazyColumn( + modifier = modifier + .fillMaxSize() + .background(scheme.background) + ) { + item { + Box { + MediaHero( + imageUrl = movie.heroImageUrl, + backgroundColor = scheme.background, + heightFraction = 0.30f, + modifier = Modifier.fillMaxWidth() + ) + MovieTopBar(onBack = onBack) + } + } + item { + Column(modifier = hPad) { + Text( + text = movie.title, + color = scheme.onBackground, + fontSize = 32.sp, + fontWeight = FontWeight.Bold, + lineHeight = 38.sp + ) + Spacer(modifier = Modifier.height(16.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + MediaMetaChip(text = movie.year) + MediaMetaChip(text = movie.rating) + MediaMetaChip(text = movie.runtime) + MediaMetaChip( + text = movie.format, + background = scheme.primary.copy(alpha = 0.2f), + border = scheme.primary.copy(alpha = 0.3f), + textColor = scheme.primary + ) + } + } + } + item { + Spacer(modifier = Modifier.height(24.dp)) + MediaSynopsis( + synopsis = movie.synopsis, + modifier = hPad ) } - ) { innerPadding -> - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - ) { - MediaHero( - imageUrl = movie.heroImageUrl, - backgroundColor = MaterialTheme.colorScheme.background, - heightFraction = 0.30f, - modifier = Modifier.fillMaxWidth() - ) - MovieDetails( - movie = movie, - onPlay = onPlay, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(bottom = innerPadding.calculateBottomPadding()) + item { + Spacer(modifier = Modifier.height(24.dp)) + Row(modifier = hPad) { + MediaResumeButton( + text = if (movie.progress == null) "Play" else "Resume", + progress = movie.progress?.div(100)?.toFloat() ?: 0f, + onClick = onPlay, + modifier = Modifier.sizeIn(maxWidth = 200.dp) + ) + VerticalDivider( + color = scheme.secondary, + thickness = 4.dp, + modifier = Modifier + .height(48.dp) + .padding(horizontal = 16.dp, vertical = 8.dp) + ) + Row { + MediaActionButton( + backgroundColor = scheme.secondary, + iconColor = scheme.onSecondary, + icon = Icons.Outlined.Add, + height = 48.dp + ) + } + } + } + item { + Spacer(modifier = Modifier.height(24.dp)) + MediaPlaybackSettings( + backgroundColor = scheme.surface, + foregroundColor = scheme.onSurface, + audioTrack = movie.audioTrack, + subtitles = movie.subtitles, + modifier = hPad ) } + if (movie.cast.isNotEmpty()) { + item { + Column(modifier = hPad) { + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = "Cast", + color = scheme.onBackground, + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(12.dp)) + MediaCastRow(cast = movie.cast) + Spacer(modifier = Modifier.height(16.dp)) + } + } + } } } diff --git a/app-tv/src/main/java/hu/bbara/purefin/app/content/series/SeriesComponents.kt b/app-tv/src/main/java/hu/bbara/purefin/app/content/series/SeriesComponents.kt index dbdf7e3..3639614 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/app/content/series/SeriesComponents.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/app/content/series/SeriesComponents.kt @@ -1,5 +1,6 @@ package hu.bbara.purefin.app.content.series +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -35,10 +36,16 @@ 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.focus.onFocusChanged import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -124,7 +131,7 @@ internal fun SeasonTabs( SeasonTab( name = season.name, isSelected = season == selectedSeason, - modifier = Modifier.clickable { onSelect(season) } + onSelect = { onSelect(season) } ) } } @@ -134,28 +141,34 @@ internal fun SeasonTabs( private fun SeasonTab( name: String, isSelected: Boolean, + onSelect: () -> Unit, modifier: Modifier = Modifier ) { val scheme = MaterialTheme.colorScheme val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.7f) - val color = if (isSelected) scheme.primary else mutedStrong - val borderColor = if (isSelected) scheme.primary else Color.Transparent + var isFocused by remember { mutableStateOf(false) } + val color = if (isSelected || isFocused) scheme.primary else mutedStrong + val underlineColor = if (isSelected || isFocused) scheme.primary else Color.Transparent + val underlineHeight = if (isFocused) 3.dp else 2.dp + Column( modifier = modifier .padding(bottom = 8.dp) + .onFocusChanged { isFocused = it.isFocused } + .clickable { onSelect() } ) { Text( text = name, color = color, fontSize = 13.sp, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium + fontWeight = if (isSelected || isFocused) FontWeight.Bold else FontWeight.Medium ) Spacer(modifier = Modifier.height(8.dp)) Box( modifier = Modifier - .height(2.dp) + .height(underlineHeight) .width(52.dp) - .background(borderColor) + .background(underlineColor) ) } } @@ -191,9 +204,14 @@ private fun EpisodeCard( ) { val scheme = MaterialTheme.colorScheme val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.7f) + var isFocused by remember { mutableStateOf(false) } + val scale by animateFloatAsState(targetValue = if (isFocused) 1.07f else 1.0f, label = "scale") + Column( modifier = Modifier .width(260.dp) + .graphicsLayer { scaleX = scale; scaleY = scale } + .onFocusChanged { isFocused = it.isFocused } .clickable { viewModel.onSelectEpisode( seriesId = episode.seriesId, seasonId = episode.seasonId, @@ -207,7 +225,11 @@ private fun EpisodeCard( .aspectRatio(16f / 9f) .clip(RoundedCornerShape(12.dp)) .background(scheme.surface) - .border(1.dp, scheme.outlineVariant, RoundedCornerShape(12.dp)) + .border( + width = if (isFocused) 2.dp else 1.dp, + color = if (isFocused) scheme.primary else scheme.outlineVariant, + shape = RoundedCornerShape(12.dp) + ) ) { PurefinAsyncImage( model = episode.heroImageUrl, diff --git a/app-tv/src/main/java/hu/bbara/purefin/app/content/series/SeriesScreen.kt b/app-tv/src/main/java/hu/bbara/purefin/app/content/series/SeriesScreen.kt index 36c079a..132d26a 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/app/content/series/SeriesScreen.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/app/content/series/SeriesScreen.kt @@ -1,15 +1,15 @@ package hu.bbara.purefin.app.content.series +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer 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.rememberScrollState -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -61,47 +61,35 @@ private fun SeriesScreenInternal( ) { val scheme = MaterialTheme.colorScheme val textMutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.7f) + val hPad = Modifier.padding(horizontal = 16.dp) - fun getDefaultSeason() : Season { + fun getDefaultSeason(): Season { for (season in series.seasons) { - val firstUnwatchedEpisode = season.episodes.firstOrNull { - it.watched.not() - } - if (firstUnwatchedEpisode != null) { - return season - } + val firstUnwatchedEpisode = season.episodes.firstOrNull { it.watched.not() } + if (firstUnwatchedEpisode != null) return season } return series.seasons.first() } val selectedSeason = remember { mutableStateOf(getDefaultSeason()) } - Scaffold( - modifier = modifier, - containerColor = MaterialTheme.colorScheme.background, - topBar = { - SeriesTopBar( - onBack = onBack, - modifier = Modifier - ) + LazyColumn( + modifier = modifier + .fillMaxSize() + .background(scheme.background) + ) { + item { + Box { + MediaHero( + imageUrl = series.heroImageUrl, + heightFraction = 0.30f, + backgroundColor = scheme.background, + modifier = Modifier.fillMaxWidth() + ) + SeriesTopBar(onBack = onBack) + } } - ) { innerPadding -> - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - ) { - MediaHero( - imageUrl = series.heroImageUrl, - heightFraction = 0.30f, - backgroundColor = MaterialTheme.colorScheme.background, - modifier = Modifier.fillMaxWidth() - ) - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(bottom = innerPadding.calculateBottomPadding()) - ) { + item { + Column(modifier = hPad) { Text( text = series.name, color = scheme.onBackground, @@ -111,34 +99,52 @@ private fun SeriesScreenInternal( ) Spacer(modifier = Modifier.height(16.dp)) SeriesMetaChips(series = series) - Spacer(modifier = Modifier.height(24.dp)) - SeriesActionButtons() - Spacer(modifier = Modifier.height(24.dp)) - MediaSynopsis( - synopsis = series.synopsis, - bodyColor = textMutedStrong, - bodyFontSize = 13.sp, - bodyLineHeight = null, - titleSpacing = 8.dp - ) - Spacer(modifier = Modifier.height(24.dp)) - SeasonTabs( - seasons = series.seasons, - selectedSeason = selectedSeason.value, - onSelect = { selectedSeason.value = it } - ) - EpisodeCarousel( - episodes = selectedSeason.value.episodes, - ) - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = "Cast", - color = scheme.onBackground, - fontSize = 18.sp, - fontWeight = FontWeight.Bold - ) - Spacer(modifier = Modifier.height(12.dp)) - CastRow(cast = series.cast) + } + } + item { + Spacer(modifier = Modifier.height(24.dp)) + SeriesActionButtons(modifier = hPad) + } + item { + Spacer(modifier = Modifier.height(24.dp)) + MediaSynopsis( + synopsis = series.synopsis, + bodyColor = textMutedStrong, + bodyFontSize = 13.sp, + bodyLineHeight = null, + titleSpacing = 8.dp, + modifier = hPad + ) + } + item { + Spacer(modifier = Modifier.height(24.dp)) + SeasonTabs( + seasons = series.seasons, + selectedSeason = selectedSeason.value, + onSelect = { selectedSeason.value = it }, + modifier = hPad + ) + } + item { + EpisodeCarousel( + episodes = selectedSeason.value.episodes, + modifier = hPad + ) + } + if (series.cast.isNotEmpty()) { + item { + Column(modifier = hPad) { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Cast", + color = scheme.onBackground, + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(12.dp)) + CastRow(cast = series.cast) + Spacer(modifier = Modifier.height(16.dp)) + } } } } diff --git a/app-tv/src/main/java/hu/bbara/purefin/common/ui/PosterCard.kt b/app-tv/src/main/java/hu/bbara/purefin/common/ui/PosterCard.kt index 851172d..ba8b941 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/common/ui/PosterCard.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/common/ui/PosterCard.kt @@ -1,5 +1,6 @@ package hu.bbara.purefin.common.ui +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -12,9 +13,15 @@ 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.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity @@ -41,6 +48,8 @@ fun PosterCard( val scheme = MaterialTheme.colorScheme val context = LocalContext.current val density = LocalDensity.current + var isFocused by remember { mutableStateOf(false) } + val scale by animateFloatAsState(targetValue = if (isFocused) 1.07f else 1.0f, label = "scale") val posterWidth = 144.dp val posterHeight = posterWidth * 3 / 2 @@ -64,6 +73,7 @@ fun PosterCard( Column( modifier = Modifier .width(posterWidth) + .graphicsLayer { scaleX = scale; scaleY = scale } ) { Box() { PurefinAsyncImage( @@ -72,8 +82,13 @@ fun PosterCard( modifier = Modifier .aspectRatio(2f / 3f) .clip(RoundedCornerShape(14.dp)) - .border(1.dp, scheme.outlineVariant.copy(alpha = 0.3f), RoundedCornerShape(14.dp)) + .border( + width = if (isFocused) 2.dp else 1.dp, + color = if (isFocused) scheme.primary else scheme.outlineVariant.copy(alpha = 0.3f), + shape = RoundedCornerShape(14.dp) + ) .background(scheme.surfaceVariant) + .onFocusChanged { isFocused = it.isFocused } .clickable(onClick = { openItem(item) }), contentScale = ContentScale.Crop ) diff --git a/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/GhostIconButton.kt b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/GhostIconButton.kt index 64ad605..a7a77ec 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/GhostIconButton.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/GhostIconButton.kt @@ -1,6 +1,9 @@ package hu.bbara.purefin.common.ui.components +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size @@ -8,9 +11,16 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +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.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp @@ -22,12 +32,18 @@ fun GhostIconButton( modifier: Modifier = Modifier ) { val scheme = MaterialTheme.colorScheme + var isFocused by remember { mutableStateOf(false) } + val scale by animateFloatAsState(targetValue = if (isFocused) 1.1f else 1.0f, label = "scale") + val borderColor by animateColorAsState(targetValue = if (isFocused) scheme.primary else Color.Transparent, label = "border") Box( modifier = modifier + .graphicsLayer { scaleX = scale; scaleY = scale } .size(52.dp) + .border(if (isFocused) 2.5.dp else 0.dp, borderColor, CircleShape) .clip(CircleShape) - .background(scheme.background.copy(alpha = 0.65f)) + .background(if (isFocused) scheme.primary.copy(alpha = 0.25f) else scheme.background.copy(alpha = 0.65f)) + .onFocusChanged { isFocused = it.isFocused } .clickable { onClick() }, contentAlignment = Alignment.Center ) { @@ -37,4 +53,4 @@ fun GhostIconButton( tint = scheme.onBackground ) } -} \ No newline at end of file +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaActionButton.kt b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaActionButton.kt index 8bcf97d..380fb62 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaActionButton.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaActionButton.kt @@ -1,18 +1,29 @@ package hu.bbara.purefin.common.ui.components +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +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.focus.onFocusChanged import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp @Composable fun MediaActionButton( @@ -23,14 +34,22 @@ fun MediaActionButton( height: Dp, onClick: () -> Unit = {}, ) { + val scheme = MaterialTheme.colorScheme + var isFocused by remember { mutableStateOf(false) } + val scale by animateFloatAsState(targetValue = if (isFocused) 1.1f else 1.0f, label = "scale") + val borderColor by animateColorAsState(targetValue = if (isFocused) scheme.primary else Color.Transparent, label = "border") + Box( modifier = modifier + .graphicsLayer { scaleX = scale; scaleY = scale } .size(height) + .border(if (isFocused) 2.5.dp else 0.dp, borderColor, CircleShape) .clip(CircleShape) .background(backgroundColor.copy(alpha = 0.6f)) + .onFocusChanged { isFocused = it.isFocused } .clickable { onClick() }, contentAlignment = Alignment.Center ) { Icon(imageVector = icon, contentDescription = null, tint = iconColor) } -} \ No newline at end of file +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaResumeButton.kt b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaResumeButton.kt index 3b1c862..85ad8a2 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaResumeButton.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaResumeButton.kt @@ -1,6 +1,8 @@ package hu.bbara.purefin.common.ui.components +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -18,11 +20,18 @@ import androidx.compose.material3.Icon 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.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -36,11 +45,16 @@ fun MediaResumeButton( ) { val primaryColor = MaterialTheme.colorScheme.primary val onPrimaryColor = MaterialTheme.colorScheme.onPrimary + var isFocused by remember { mutableStateOf(false) } + val scale by animateFloatAsState(targetValue = if (isFocused) 1.05f else 1.0f, label = "scale") BoxWithConstraints( modifier = modifier + .graphicsLayer { scaleX = scale; scaleY = scale } .height(52.dp) + .border(2.5.dp, if (isFocused) onPrimaryColor else Color.Transparent, RoundedCornerShape(50)) .clip(RoundedCornerShape(50)) + .onFocusChanged { isFocused = it.isFocused } .clickable(onClick = onClick) ) { // Bottom layer: inverted colors (visible for the remaining %) @@ -77,7 +91,7 @@ fun MediaResumeButton( } @Composable -private fun ButtonContent(text: String, color: androidx.compose.ui.graphics.Color) { +private fun ButtonContent(text: String, color: Color) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center diff --git a/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/PurefinIconButton.kt b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/PurefinIconButton.kt index 9a5dc9c..e640dc1 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/PurefinIconButton.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/PurefinIconButton.kt @@ -1,6 +1,9 @@ package hu.bbara.purefin.common.ui.components +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size @@ -8,9 +11,16 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +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.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp @@ -23,12 +33,17 @@ fun PurefinIconButton( size: Int = 52 ) { val scheme = MaterialTheme.colorScheme + var isFocused by remember { mutableStateOf(false) } + val scale by animateFloatAsState(targetValue = if (isFocused) 1.1f else 1.0f, label = "scale") Box( modifier = modifier + .graphicsLayer { scaleX = scale; scaleY = scale } .size(size.dp) + .border(if (isFocused) 2.5.dp else 0.dp, if (isFocused) scheme.onPrimary else Color.Transparent, CircleShape) .clip(CircleShape) - .background(scheme.secondary) + .background(if (isFocused) scheme.primary else scheme.secondary) + .onFocusChanged { isFocused = it.isFocused } .clickable { onClick() }, contentAlignment = Alignment.Center ) { diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeDrawer.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeDrawer.kt index 312e645..7f33cfd 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeDrawer.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeDrawer.kt @@ -1,6 +1,7 @@ package hu.bbara.purefin.tv.home.ui import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -19,8 +20,13 @@ import androidx.compose.material3.Icon 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.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -132,13 +138,20 @@ fun TvHomeDrawerNavItem( onLibrarySelected: (TvHomeNavItem) -> Unit ) { val scheme = MaterialTheme.colorScheme - val background = if (item.selected) scheme.primary.copy(alpha = 0.12f) else Color.Transparent - val tint = if (item.selected) scheme.primary else scheme.onSurfaceVariant + var isFocused by remember { mutableStateOf(false) } + val background = when { + isFocused -> scheme.primary.copy(alpha = 0.28f) + item.selected -> scheme.primary.copy(alpha = 0.12f) + else -> Color.Transparent + } + val tint = if (item.selected || isFocused) scheme.primary else scheme.onSurfaceVariant Row( modifier = modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 4.dp) + .then(if (isFocused) Modifier.border(2.dp, scheme.primary, RoundedCornerShape(12.dp)) else Modifier) .background(background, RoundedCornerShape(12.dp)) + .onFocusChanged { isFocused = it.isFocused } .clickable { onLibrarySelected(item) } .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically @@ -150,7 +163,7 @@ fun TvHomeDrawerNavItem( ) Text( text = item.label, - color = if (item.selected) scheme.primary else scheme.onBackground, + color = if (item.selected || isFocused) scheme.primary else scheme.onBackground, fontSize = 15.sp, fontWeight = FontWeight.Medium, modifier = Modifier.padding(start = 12.dp) diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeSections.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeSections.kt index 05c13a9..c6a42a4 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeSections.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeSections.kt @@ -1,5 +1,6 @@ package hu.bbara.purefin.tv.home.ui +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -27,9 +28,15 @@ import androidx.compose.material3.IconButtonColors 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.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity @@ -87,6 +94,8 @@ fun TvContinueWatchingCard( val context = LocalContext.current val density = LocalDensity.current + var isFocused by remember { mutableStateOf(false) } + val scale by animateFloatAsState(targetValue = if (isFocused) 1.07f else 1.0f, label = "scale") val imageUrl = when (item.type) { BaseItemKind.MOVIE -> item.movie?.heroImageUrl @@ -118,13 +127,18 @@ fun TvContinueWatchingCard( modifier = modifier .width(cardWidth) .wrapContentHeight() + .graphicsLayer { scaleX = scale; scaleY = scale } ) { Box( modifier = Modifier .width(cardWidth) .aspectRatio(16f / 9f) .clip(RoundedCornerShape(16.dp)) - .border(1.dp, scheme.outlineVariant.copy(alpha = 0.3f), RoundedCornerShape(16.dp)) + .border( + width = if (isFocused) 2.dp else 1.dp, + color = if (isFocused) scheme.primary else scheme.outlineVariant.copy(alpha = 0.3f), + shape = RoundedCornerShape(16.dp) + ) .background(scheme.surfaceVariant) ) { PurefinAsyncImage( @@ -132,6 +146,7 @@ fun TvContinueWatchingCard( contentDescription = null, modifier = Modifier .fillMaxSize() + .onFocusChanged { isFocused = it.isFocused } .clickable { openItem(item) }, @@ -201,6 +216,8 @@ fun TvNextUpCard( val context = LocalContext.current val density = LocalDensity.current + var isFocused by remember { mutableStateOf(false) } + val scale by animateFloatAsState(targetValue = if (isFocused) 1.07f else 1.0f, label = "scale") val imageUrl = item.episode.heroImageUrl @@ -221,13 +238,18 @@ fun TvNextUpCard( modifier = modifier .width(cardWidth) .wrapContentHeight() + .graphicsLayer { scaleX = scale; scaleY = scale } ) { Box( modifier = Modifier .width(cardWidth) .aspectRatio(16f / 9f) .clip(RoundedCornerShape(16.dp)) - .border(1.dp, scheme.outlineVariant.copy(alpha = 0.3f), RoundedCornerShape(16.dp)) + .border( + width = if (isFocused) 2.dp else 1.dp, + color = if (isFocused) scheme.primary else scheme.outlineVariant.copy(alpha = 0.3f), + shape = RoundedCornerShape(16.dp) + ) .background(scheme.surfaceVariant) ) { PurefinAsyncImage( @@ -235,6 +257,7 @@ fun TvNextUpCard( contentDescription = null, modifier = Modifier .fillMaxSize() + .onFocusChanged { isFocused = it.isFocused } .clickable { openItem(item) }, diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/player/TvPlayerScreen.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/player/TvPlayerScreen.kt index 818a1c4..ef9d615 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/tv/player/TvPlayerScreen.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/player/TvPlayerScreen.kt @@ -8,7 +8,10 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -62,10 +65,12 @@ 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.focus.onFocusChanged import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight @@ -650,14 +655,18 @@ private fun TvTrackSelectionPanel( ) { options.forEach { option -> val selected = option.id == selectedId + var isTrackFocused by remember { mutableStateOf(false) } Box( modifier = Modifier .fillMaxWidth() + .then(if (isTrackFocused) Modifier.border(2.dp, scheme.primary, RoundedCornerShape(12.dp)) else Modifier) .clip(RoundedCornerShape(12.dp)) .background( - if (selected) scheme.primary.copy(alpha = 0.15f) + if (isTrackFocused) scheme.primary.copy(alpha = 0.3f) + else if (selected) scheme.primary.copy(alpha = 0.15f) else scheme.surfaceVariant.copy(alpha = 0.6f) ) + .onFocusChanged { isTrackFocused = it.isFocused } .clickable { onSelect(option) } .padding(horizontal = 20.dp, vertical = 14.dp) ) { @@ -724,14 +733,18 @@ private fun TvQueuePanel( } else { LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) { items(uiState.queue) { item -> + var isQueueFocused by remember { mutableStateOf(false) } Row( modifier = Modifier .fillMaxWidth() + .then(if (isQueueFocused) Modifier.border(2.dp, scheme.primary, RoundedCornerShape(12.dp)) else Modifier) .clip(RoundedCornerShape(12.dp)) .background( - if (item.isCurrent) scheme.primary.copy(alpha = 0.15f) + if (isQueueFocused) scheme.primary.copy(alpha = 0.35f) + else if (item.isCurrent) scheme.primary.copy(alpha = 0.15f) else scheme.surfaceVariant.copy(alpha = 0.8f) ) + .onFocusChanged { isQueueFocused = it.isFocused } .clickable { onSelect(item.id) } .padding(12.dp), verticalAlignment = Alignment.CenterVertically, @@ -790,12 +803,19 @@ private fun TvIconButton( modifier: Modifier = Modifier ) { val scheme = MaterialTheme.colorScheme + var isFocused by remember { mutableStateOf(false) } + val scale by animateFloatAsState(targetValue = if (isFocused) 1.1f else 1.0f, label = "scale") + val borderColor by animateColorAsState(targetValue = if (isFocused) scheme.primary else Color.Transparent, label = "border") + Box( modifier = modifier + .graphicsLayer { scaleX = scale; scaleY = scale } .widthIn(min = size.dp) .height(size.dp) + .border(if (isFocused) 2.dp else 0.dp, borderColor, RoundedCornerShape(50)) .clip(RoundedCornerShape(50)) - .background(scheme.background.copy(alpha = 0.65f)) + .background(if (isFocused) scheme.primary.copy(alpha = 0.5f) else scheme.background.copy(alpha = 0.65f)) + .onFocusChanged { isFocused = it.isFocused } .clickable { onClick() }, contentAlignment = Alignment.Center ) {