feat: Add TV focus states and refactor content screens to LazyColumn

- Add animated focus feedback (scale, border, background) to all interactive
  TV UI components: PosterCard, GhostIconButton, MediaActionButton,
  MediaResumeButton, PurefinIconButton, TvHomeDrawer nav items,
  TvHomeSections cards, TvPlayerScreen track/queue panels
- Refactor EpisodeScreen, MovieScreen, and SeriesScreen from Scaffold +
  verticalScroll to LazyColumn layout with Box-overlaid top bar
- Switch MovieComponents and MovieScreen from MovieUiModel to core Movie model
- Conditionally render cast sections only when cast is non-empty
This commit is contained in:
2026-02-27 17:23:47 +01:00
parent cccb20312b
commit 2b27ce946d
13 changed files with 519 additions and 159 deletions

View File

@@ -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
)
}
) { innerPadding ->
Column(
modifier = Modifier
LazyColumn(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.background(scheme.background)
) {
item {
Box {
MediaHero(
imageUrl = episode.heroImageUrl,
backgroundColor = MaterialTheme.colorScheme.background,
backgroundColor = scheme.background,
heightFraction = 0.30f,
modifier = Modifier.fillMaxWidth()
)
EpisodeDetails(
episode = episode,
onPlay = onPlay,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = innerPadding.calculateBottomPadding())
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
)
}
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))
}
}
}
}
}

View File

@@ -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,6 +132,7 @@ internal fun MovieDetails(
subtitles = movie.subtitles
)
if (movie.cast.isNotEmpty()) {
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "Cast",
@@ -141,7 +142,8 @@ internal fun MovieDetails(
)
Spacer(modifier = Modifier.height(12.dp))
MediaCastRow(
cast = emptyList()
cast = movie.cast
)
}
}
}

View File

@@ -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
)
}
) { innerPadding ->
Column(
modifier = Modifier
val scheme = MaterialTheme.colorScheme
val hPad = Modifier.padding(horizontal = 16.dp)
LazyColumn(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.background(scheme.background)
) {
item {
Box {
MediaHero(
imageUrl = movie.heroImageUrl,
backgroundColor = MaterialTheme.colorScheme.background,
backgroundColor = scheme.background,
heightFraction = 0.30f,
modifier = Modifier.fillMaxWidth()
)
MovieDetails(
movie = movie,
onPlay = onPlay,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = innerPadding.calculateBottomPadding())
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
)
}
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))
}
}
}
}
}

View File

@@ -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,

View File

@@ -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 {
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<Season>(getDefaultSeason()) }
Scaffold(
modifier = modifier,
containerColor = MaterialTheme.colorScheme.background,
topBar = {
SeriesTopBar(
onBack = onBack,
modifier = Modifier
)
}
) { innerPadding ->
Column(
modifier = Modifier
LazyColumn(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.background(scheme.background)
) {
item {
Box {
MediaHero(
imageUrl = series.heroImageUrl,
heightFraction = 0.30f,
backgroundColor = MaterialTheme.colorScheme.background,
backgroundColor = scheme.background,
modifier = Modifier.fillMaxWidth()
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = innerPadding.calculateBottomPadding())
) {
SeriesTopBar(onBack = onBack)
}
}
item {
Column(modifier = hPad) {
Text(
text = series.name,
color = scheme.onBackground,
@@ -111,25 +99,41 @@ private fun SeriesScreenInternal(
)
Spacer(modifier = Modifier.height(16.dp))
SeriesMetaChips(series = series)
}
}
item {
Spacer(modifier = Modifier.height(24.dp))
SeriesActionButtons()
SeriesActionButtons(modifier = hPad)
}
item {
Spacer(modifier = Modifier.height(24.dp))
MediaSynopsis(
synopsis = series.synopsis,
bodyColor = textMutedStrong,
bodyFontSize = 13.sp,
bodyLineHeight = null,
titleSpacing = 8.dp
titleSpacing = 8.dp,
modifier = hPad
)
}
item {
Spacer(modifier = Modifier.height(24.dp))
SeasonTabs(
seasons = series.seasons,
selectedSeason = selectedSeason.value,
onSelect = { selectedSeason.value = it }
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",
@@ -139,6 +143,8 @@ private fun SeriesScreenInternal(
)
Spacer(modifier = Modifier.height(12.dp))
CastRow(cast = series.cast)
Spacer(modifier = Modifier.height(16.dp))
}
}
}
}

View File

@@ -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
)

View File

@@ -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
) {

View File

@@ -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,11 +34,19 @@ 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
) {

View File

@@ -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

View File

@@ -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
) {

View File

@@ -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)

View File

@@ -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)
},

View File

@@ -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
) {