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 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.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.verticalScroll 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.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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel 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.PurefinWaitingScreen
import hu.bbara.purefin.common.ui.components.MediaActionButton
import hu.bbara.purefin.common.ui.components.MediaHero 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.data.navigation.EpisodeDto
import hu.bbara.purefin.core.model.Episode import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.feature.shared.content.episode.EpisodeScreenViewModel import hu.bbara.purefin.feature.shared.content.episode.EpisodeScreenViewModel
@@ -26,7 +46,6 @@ fun EpisodeScreen(
viewModel: EpisodeScreenViewModel = hiltViewModel(), viewModel: EpisodeScreenViewModel = hiltViewModel(),
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
LaunchedEffect(episode) { LaunchedEffect(episode) {
viewModel.selectEpisode( viewModel.selectEpisode(
seriesId = episode.seriesId, seriesId = episode.seriesId,
@@ -50,6 +69,7 @@ fun EpisodeScreen(
) )
} }
@OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
private fun EpisodeScreenInternal( private fun EpisodeScreenInternal(
episode: Episode, episode: Episode,
@@ -57,36 +77,116 @@ private fun EpisodeScreenInternal(
onPlay: () -> Unit, onPlay: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val scheme = MaterialTheme.colorScheme
val hPad = Modifier.padding(horizontal = 16.dp)
Scaffold( LazyColumn(
modifier = modifier, modifier = modifier
containerColor = MaterialTheme.colorScheme.background, .fillMaxSize()
topBar = { .background(scheme.background)
EpisodeTopBar( ) {
onBack = onBack, item {
modifier = Modifier 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 -> item {
Column( Spacer(modifier = Modifier.height(24.dp))
modifier = Modifier Row(modifier = hPad) {
.fillMaxSize() MediaResumeButton(
.verticalScroll(rememberScrollState()) text = if (episode.progress == null) "Play" else "Resume",
) { progress = episode.progress?.div(100)?.toFloat() ?: 0f,
MediaHero( onClick = onPlay,
imageUrl = episode.heroImageUrl, modifier = Modifier.sizeIn(maxWidth = 200.dp)
backgroundColor = MaterialTheme.colorScheme.background, )
heightFraction = 0.30f, VerticalDivider(
modifier = Modifier.fillMaxWidth() color = scheme.secondary,
) thickness = 4.dp,
EpisodeDetails( modifier = Modifier
episode = episode, .height(48.dp)
onPlay = onPlay, .padding(horizontal = 16.dp, vertical = 8.dp)
modifier = Modifier )
.fillMaxWidth() Row {
.padding(horizontal = 16.dp) MediaActionButton(
.padding(bottom = innerPadding.calculateBottomPadding()) 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.MediaActionButton
import hu.bbara.purefin.common.ui.components.MediaPlaybackSettings import hu.bbara.purefin.common.ui.components.MediaPlaybackSettings
import hu.bbara.purefin.common.ui.components.MediaResumeButton 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 @Composable
internal fun MovieTopBar( internal fun MovieTopBar(
@@ -64,7 +64,7 @@ internal fun MovieTopBar(
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
internal fun MovieDetails( internal fun MovieDetails(
movie: MovieUiModel, movie: Movie,
onPlay: () -> Unit, onPlay: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
@@ -132,16 +132,18 @@ internal fun MovieDetails(
subtitles = movie.subtitles subtitles = movie.subtitles
) )
Spacer(modifier = Modifier.height(24.dp)) if (movie.cast.isNotEmpty()) {
Text( Spacer(modifier = Modifier.height(24.dp))
text = "Cast", Text(
color = scheme.onBackground, text = "Cast",
fontSize = 18.sp, color = scheme.onBackground,
fontWeight = FontWeight.Bold fontSize = 18.sp,
) fontWeight = FontWeight.Bold
Spacer(modifier = Modifier.height(12.dp)) )
MediaCastRow( Spacer(modifier = Modifier.height(12.dp))
cast = emptyList() MediaCastRow(
) cast = movie.cast
)
}
} }
} }

View File

@@ -1,24 +1,44 @@
package hu.bbara.purefin.app.content.movie 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.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.verticalScroll 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.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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel 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.PurefinWaitingScreen
import hu.bbara.purefin.common.ui.components.MediaActionButton
import hu.bbara.purefin.common.ui.components.MediaHero 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.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.MovieScreenViewModel
import hu.bbara.purefin.feature.shared.content.movie.MovieUiModel
@Composable @Composable
fun MovieScreen( fun MovieScreen(
@@ -42,42 +62,117 @@ fun MovieScreen(
} }
} }
@OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
private fun MovieScreenInternal( private fun MovieScreenInternal(
movie: MovieUiModel, movie: Movie,
onBack: () -> Unit, onBack: () -> Unit,
onPlay: () -> Unit, onPlay: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Scaffold( val scheme = MaterialTheme.colorScheme
modifier = modifier, val hPad = Modifier.padding(horizontal = 16.dp)
containerColor = MaterialTheme.colorScheme.background,
topBar = { LazyColumn(
MovieTopBar( modifier = modifier
onBack = onBack, .fillMaxSize()
modifier = Modifier .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 -> item {
Column( Spacer(modifier = Modifier.height(24.dp))
modifier = Modifier Row(modifier = hPad) {
.fillMaxSize() MediaResumeButton(
.verticalScroll(rememberScrollState()) text = if (movie.progress == null) "Play" else "Resume",
) { progress = movie.progress?.div(100)?.toFloat() ?: 0f,
MediaHero( onClick = onPlay,
imageUrl = movie.heroImageUrl, modifier = Modifier.sizeIn(maxWidth = 200.dp)
backgroundColor = MaterialTheme.colorScheme.background, )
heightFraction = 0.30f, VerticalDivider(
modifier = Modifier.fillMaxWidth() color = scheme.secondary,
) thickness = 4.dp,
MovieDetails( modifier = Modifier
movie = movie, .height(48.dp)
onPlay = onPlay, .padding(horizontal = 16.dp, vertical = 8.dp)
modifier = Modifier )
.fillMaxWidth() Row {
.padding(horizontal = 16.dp) MediaActionButton(
.padding(bottom = innerPadding.calculateBottomPadding()) 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 package hu.bbara.purefin.app.content.series
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -35,10 +36,16 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -124,7 +131,7 @@ internal fun SeasonTabs(
SeasonTab( SeasonTab(
name = season.name, name = season.name,
isSelected = season == selectedSeason, isSelected = season == selectedSeason,
modifier = Modifier.clickable { onSelect(season) } onSelect = { onSelect(season) }
) )
} }
} }
@@ -134,28 +141,34 @@ internal fun SeasonTabs(
private fun SeasonTab( private fun SeasonTab(
name: String, name: String,
isSelected: Boolean, isSelected: Boolean,
onSelect: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.7f) val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.7f)
val color = if (isSelected) scheme.primary else mutedStrong var isFocused by remember { mutableStateOf(false) }
val borderColor = if (isSelected) scheme.primary else Color.Transparent 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( Column(
modifier = modifier modifier = modifier
.padding(bottom = 8.dp) .padding(bottom = 8.dp)
.onFocusChanged { isFocused = it.isFocused }
.clickable { onSelect() }
) { ) {
Text( Text(
text = name, text = name,
color = color, color = color,
fontSize = 13.sp, 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)) Spacer(modifier = Modifier.height(8.dp))
Box( Box(
modifier = Modifier modifier = Modifier
.height(2.dp) .height(underlineHeight)
.width(52.dp) .width(52.dp)
.background(borderColor) .background(underlineColor)
) )
} }
} }
@@ -191,9 +204,14 @@ private fun EpisodeCard(
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.7f) 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( Column(
modifier = Modifier modifier = Modifier
.width(260.dp) .width(260.dp)
.graphicsLayer { scaleX = scale; scaleY = scale }
.onFocusChanged { isFocused = it.isFocused }
.clickable { viewModel.onSelectEpisode( .clickable { viewModel.onSelectEpisode(
seriesId = episode.seriesId, seriesId = episode.seriesId,
seasonId = episode.seasonId, seasonId = episode.seasonId,
@@ -207,7 +225,11 @@ private fun EpisodeCard(
.aspectRatio(16f / 9f) .aspectRatio(16f / 9f)
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
.background(scheme.surface) .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( PurefinAsyncImage(
model = episode.heroImageUrl, model = episode.heroImageUrl,

View File

@@ -1,15 +1,15 @@
package hu.bbara.purefin.app.content.series 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.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -61,47 +61,35 @@ private fun SeriesScreenInternal(
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
val textMutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.7f) 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) { for (season in series.seasons) {
val firstUnwatchedEpisode = season.episodes.firstOrNull { val firstUnwatchedEpisode = season.episodes.firstOrNull { it.watched.not() }
it.watched.not() if (firstUnwatchedEpisode != null) return season
}
if (firstUnwatchedEpisode != null) {
return season
}
} }
return series.seasons.first() return series.seasons.first()
} }
val selectedSeason = remember { mutableStateOf<Season>(getDefaultSeason()) } val selectedSeason = remember { mutableStateOf<Season>(getDefaultSeason()) }
Scaffold( LazyColumn(
modifier = modifier, modifier = modifier
containerColor = MaterialTheme.colorScheme.background, .fillMaxSize()
topBar = { .background(scheme.background)
SeriesTopBar( ) {
onBack = onBack, item {
modifier = Modifier Box {
) MediaHero(
imageUrl = series.heroImageUrl,
heightFraction = 0.30f,
backgroundColor = scheme.background,
modifier = Modifier.fillMaxWidth()
)
SeriesTopBar(onBack = onBack)
}
} }
) { innerPadding -> item {
Column( Column(modifier = hPad) {
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())
) {
Text( Text(
text = series.name, text = series.name,
color = scheme.onBackground, color = scheme.onBackground,
@@ -111,34 +99,52 @@ private fun SeriesScreenInternal(
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
SeriesMetaChips(series = series) SeriesMetaChips(series = series)
Spacer(modifier = Modifier.height(24.dp)) }
SeriesActionButtons() }
Spacer(modifier = Modifier.height(24.dp)) item {
MediaSynopsis( Spacer(modifier = Modifier.height(24.dp))
synopsis = series.synopsis, SeriesActionButtons(modifier = hPad)
bodyColor = textMutedStrong, }
bodyFontSize = 13.sp, item {
bodyLineHeight = null, Spacer(modifier = Modifier.height(24.dp))
titleSpacing = 8.dp MediaSynopsis(
) synopsis = series.synopsis,
Spacer(modifier = Modifier.height(24.dp)) bodyColor = textMutedStrong,
SeasonTabs( bodyFontSize = 13.sp,
seasons = series.seasons, bodyLineHeight = null,
selectedSeason = selectedSeason.value, titleSpacing = 8.dp,
onSelect = { selectedSeason.value = it } modifier = hPad
) )
EpisodeCarousel( }
episodes = selectedSeason.value.episodes, item {
) Spacer(modifier = Modifier.height(24.dp))
Spacer(modifier = Modifier.height(16.dp)) SeasonTabs(
Text( seasons = series.seasons,
text = "Cast", selectedSeason = selectedSeason.value,
color = scheme.onBackground, onSelect = { selectedSeason.value = it },
fontSize = 18.sp, modifier = hPad
fontWeight = FontWeight.Bold )
) }
Spacer(modifier = Modifier.height(12.dp)) item {
CastRow(cast = series.cast) 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))
}
} }
} }
} }

View File

@@ -1,5 +1,6 @@
package hu.bbara.purefin.common.ui package hu.bbara.purefin.common.ui
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -12,9 +13,15 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
@@ -41,6 +48,8 @@ fun PosterCard(
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
val context = LocalContext.current val context = LocalContext.current
val density = LocalDensity.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 posterWidth = 144.dp
val posterHeight = posterWidth * 3 / 2 val posterHeight = posterWidth * 3 / 2
@@ -64,6 +73,7 @@ fun PosterCard(
Column( Column(
modifier = Modifier modifier = Modifier
.width(posterWidth) .width(posterWidth)
.graphicsLayer { scaleX = scale; scaleY = scale }
) { ) {
Box() { Box() {
PurefinAsyncImage( PurefinAsyncImage(
@@ -72,8 +82,13 @@ fun PosterCard(
modifier = Modifier modifier = Modifier
.aspectRatio(2f / 3f) .aspectRatio(2f / 3f)
.clip(RoundedCornerShape(14.dp)) .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) .background(scheme.surfaceVariant)
.onFocusChanged { isFocused = it.isFocused }
.clickable(onClick = { openItem(item) }), .clickable(onClick = { openItem(item) }),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )

View File

@@ -1,6 +1,9 @@
package hu.bbara.purefin.common.ui.components 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.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size 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.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -22,12 +32,18 @@ fun GhostIconButton(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val scheme = MaterialTheme.colorScheme 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( Box(
modifier = modifier modifier = modifier
.graphicsLayer { scaleX = scale; scaleY = scale }
.size(52.dp) .size(52.dp)
.border(if (isFocused) 2.5.dp else 0.dp, borderColor, CircleShape)
.clip(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() }, .clickable { onClick() },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
@@ -37,4 +53,4 @@ fun GhostIconButton(
tint = scheme.onBackground tint = scheme.onBackground
) )
} }
} }

View File

@@ -1,18 +1,29 @@
package hu.bbara.purefin.common.ui.components 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.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable @Composable
fun MediaActionButton( fun MediaActionButton(
@@ -23,14 +34,22 @@ fun MediaActionButton(
height: Dp, height: Dp,
onClick: () -> Unit = {}, 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( Box(
modifier = modifier modifier = modifier
.graphicsLayer { scaleX = scale; scaleY = scale }
.size(height) .size(height)
.border(if (isFocused) 2.5.dp else 0.dp, borderColor, CircleShape)
.clip(CircleShape) .clip(CircleShape)
.background(backgroundColor.copy(alpha = 0.6f)) .background(backgroundColor.copy(alpha = 0.6f))
.onFocusChanged { isFocused = it.isFocused }
.clickable { onClick() }, .clickable { onClick() },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon(imageVector = icon, contentDescription = null, tint = iconColor) Icon(imageVector = icon, contentDescription = null, tint = iconColor)
} }
} }

View File

@@ -1,6 +1,8 @@
package hu.bbara.purefin.common.ui.components package hu.bbara.purefin.common.ui.components
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -18,11 +20,18 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent 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.drawscope.clipRect
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -36,11 +45,16 @@ fun MediaResumeButton(
) { ) {
val primaryColor = MaterialTheme.colorScheme.primary val primaryColor = MaterialTheme.colorScheme.primary
val onPrimaryColor = MaterialTheme.colorScheme.onPrimary 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( BoxWithConstraints(
modifier = modifier modifier = modifier
.graphicsLayer { scaleX = scale; scaleY = scale }
.height(52.dp) .height(52.dp)
.border(2.5.dp, if (isFocused) onPrimaryColor else Color.Transparent, RoundedCornerShape(50))
.clip(RoundedCornerShape(50)) .clip(RoundedCornerShape(50))
.onFocusChanged { isFocused = it.isFocused }
.clickable(onClick = onClick) .clickable(onClick = onClick)
) { ) {
// Bottom layer: inverted colors (visible for the remaining %) // Bottom layer: inverted colors (visible for the remaining %)
@@ -77,7 +91,7 @@ fun MediaResumeButton(
} }
@Composable @Composable
private fun ButtonContent(text: String, color: androidx.compose.ui.graphics.Color) { private fun ButtonContent(text: String, color: Color) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.Center

View File

@@ -1,6 +1,9 @@
package hu.bbara.purefin.common.ui.components 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.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size 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.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -23,12 +33,17 @@ fun PurefinIconButton(
size: Int = 52 size: Int = 52
) { ) {
val scheme = MaterialTheme.colorScheme 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( Box(
modifier = modifier modifier = modifier
.graphicsLayer { scaleX = scale; scaleY = scale }
.size(size.dp) .size(size.dp)
.border(if (isFocused) 2.5.dp else 0.dp, if (isFocused) scheme.onPrimary else Color.Transparent, CircleShape)
.clip(CircleShape) .clip(CircleShape)
.background(scheme.secondary) .background(if (isFocused) scheme.primary else scheme.secondary)
.onFocusChanged { isFocused = it.isFocused }
.clickable { onClick() }, .clickable { onClick() },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {

View File

@@ -1,6 +1,7 @@
package hu.bbara.purefin.tv.home.ui package hu.bbara.purefin.tv.home.ui
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -19,8 +20,13 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -132,13 +138,20 @@ fun TvHomeDrawerNavItem(
onLibrarySelected: (TvHomeNavItem) -> Unit onLibrarySelected: (TvHomeNavItem) -> Unit
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
val background = if (item.selected) scheme.primary.copy(alpha = 0.12f) else Color.Transparent var isFocused by remember { mutableStateOf(false) }
val tint = if (item.selected) scheme.primary else scheme.onSurfaceVariant 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( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp) .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)) .background(background, RoundedCornerShape(12.dp))
.onFocusChanged { isFocused = it.isFocused }
.clickable { onLibrarySelected(item) } .clickable { onLibrarySelected(item) }
.padding(horizontal = 16.dp, vertical = 12.dp), .padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
@@ -150,7 +163,7 @@ fun TvHomeDrawerNavItem(
) )
Text( Text(
text = item.label, 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, fontSize = 15.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
modifier = Modifier.padding(start = 12.dp) modifier = Modifier.padding(start = 12.dp)

View File

@@ -1,5 +1,6 @@
package hu.bbara.purefin.tv.home.ui package hu.bbara.purefin.tv.home.ui
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -27,9 +28,15 @@ import androidx.compose.material3.IconButtonColors
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
@@ -87,6 +94,8 @@ fun TvContinueWatchingCard(
val context = LocalContext.current val context = LocalContext.current
val density = LocalDensity.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) { val imageUrl = when (item.type) {
BaseItemKind.MOVIE -> item.movie?.heroImageUrl BaseItemKind.MOVIE -> item.movie?.heroImageUrl
@@ -118,13 +127,18 @@ fun TvContinueWatchingCard(
modifier = modifier modifier = modifier
.width(cardWidth) .width(cardWidth)
.wrapContentHeight() .wrapContentHeight()
.graphicsLayer { scaleX = scale; scaleY = scale }
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.width(cardWidth) .width(cardWidth)
.aspectRatio(16f / 9f) .aspectRatio(16f / 9f)
.clip(RoundedCornerShape(16.dp)) .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) .background(scheme.surfaceVariant)
) { ) {
PurefinAsyncImage( PurefinAsyncImage(
@@ -132,6 +146,7 @@ fun TvContinueWatchingCard(
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.onFocusChanged { isFocused = it.isFocused }
.clickable { .clickable {
openItem(item) openItem(item)
}, },
@@ -201,6 +216,8 @@ fun TvNextUpCard(
val context = LocalContext.current val context = LocalContext.current
val density = LocalDensity.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 val imageUrl = item.episode.heroImageUrl
@@ -221,13 +238,18 @@ fun TvNextUpCard(
modifier = modifier modifier = modifier
.width(cardWidth) .width(cardWidth)
.wrapContentHeight() .wrapContentHeight()
.graphicsLayer { scaleX = scale; scaleY = scale }
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.width(cardWidth) .width(cardWidth)
.aspectRatio(16f / 9f) .aspectRatio(16f / 9f)
.clip(RoundedCornerShape(16.dp)) .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) .background(scheme.surfaceVariant)
) { ) {
PurefinAsyncImage( PurefinAsyncImage(
@@ -235,6 +257,7 @@ fun TvNextUpCard(
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.onFocusChanged { isFocused = it.isFocused }
.clickable { .clickable {
openItem(item) openItem(item)
}, },

View File

@@ -8,7 +8,10 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally 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.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -62,10 +65,12 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -650,14 +655,18 @@ private fun TvTrackSelectionPanel(
) { ) {
options.forEach { option -> options.forEach { option ->
val selected = option.id == selectedId val selected = option.id == selectedId
var isTrackFocused by remember { mutableStateOf(false) }
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.then(if (isTrackFocused) Modifier.border(2.dp, scheme.primary, RoundedCornerShape(12.dp)) else Modifier)
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
.background( .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) else scheme.surfaceVariant.copy(alpha = 0.6f)
) )
.onFocusChanged { isTrackFocused = it.isFocused }
.clickable { onSelect(option) } .clickable { onSelect(option) }
.padding(horizontal = 20.dp, vertical = 14.dp) .padding(horizontal = 20.dp, vertical = 14.dp)
) { ) {
@@ -724,14 +733,18 @@ private fun TvQueuePanel(
} else { } else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) { LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
items(uiState.queue) { item -> items(uiState.queue) { item ->
var isQueueFocused by remember { mutableStateOf(false) }
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.then(if (isQueueFocused) Modifier.border(2.dp, scheme.primary, RoundedCornerShape(12.dp)) else Modifier)
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
.background( .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) else scheme.surfaceVariant.copy(alpha = 0.8f)
) )
.onFocusChanged { isQueueFocused = it.isFocused }
.clickable { onSelect(item.id) } .clickable { onSelect(item.id) }
.padding(12.dp), .padding(12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -790,12 +803,19 @@ private fun TvIconButton(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val scheme = MaterialTheme.colorScheme 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( Box(
modifier = modifier modifier = modifier
.graphicsLayer { scaleX = scale; scaleY = scale }
.widthIn(min = size.dp) .widthIn(min = size.dp)
.height(size.dp) .height(size.dp)
.border(if (isFocused) 2.dp else 0.dp, borderColor, RoundedCornerShape(50))
.clip(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() }, .clickable { onClick() },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {