feat(player): enhance UI responsiveness with animated color states for buttons and track selection

This commit is contained in:
2026-05-09 08:49:49 +02:00
parent 4e322e7723
commit 4e9d8c0b3b
3 changed files with 209 additions and 125 deletions

View File

@@ -54,6 +54,18 @@ internal fun TvIconButton(
targetValue = if (isFocused) scheme.primary else Color.Transparent, targetValue = if (isFocused) scheme.primary else Color.Transparent,
label = "border" label = "border"
) )
val backgroundColor by animateColorAsState(
targetValue = if (isFocused) {
scheme.primaryContainer
} else {
scheme.surfaceContainerHigh.copy(alpha = 0.72f)
},
label = "background"
)
val contentColor by animateColorAsState(
targetValue = if (isFocused) scheme.onPrimaryContainer else scheme.onSurface,
label = "content"
)
val shape = RoundedCornerShape(50) val shape = RoundedCornerShape(50)
val iconSize = (size - 24).coerceAtLeast(0).dp val iconSize = (size - 24).coerceAtLeast(0).dp
@@ -69,10 +81,7 @@ internal fun TvIconButton(
shape = shape shape = shape
) )
.clip(shape) .clip(shape)
.background( .background(backgroundColor)
if (isFocused) scheme.primary.copy(alpha = 0.5f)
else scheme.background.copy(alpha = 0.65f)
)
.focusProperties { canFocus = enabled } .focusProperties { canFocus = enabled }
.semantics { .semantics {
if (!enabled) { if (!enabled) {
@@ -86,7 +95,7 @@ internal fun TvIconButton(
Icon( Icon(
imageVector = icon, imageVector = icon,
contentDescription = contentDescription, contentDescription = contentDescription,
tint = scheme.onBackground, tint = contentColor,
modifier = buttonModifier modifier = buttonModifier
.size(size.dp) .size(size.dp)
.padding(12.dp) .padding(12.dp)
@@ -104,12 +113,12 @@ internal fun TvIconButton(
Icon( Icon(
imageVector = icon, imageVector = icon,
contentDescription = contentDescription, contentDescription = contentDescription,
tint = scheme.onBackground, tint = contentColor,
modifier = Modifier.size(iconSize) modifier = Modifier.size(iconSize)
) )
Text( Text(
text = label, text = label,
color = scheme.onBackground, color = contentColor,
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold fontWeight = FontWeight.SemiBold
) )

View File

@@ -1,5 +1,7 @@
package hu.bbara.purefin.ui.screen.player.components package hu.bbara.purefin.ui.screen.player.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.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -7,14 +9,19 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.PlayArrow
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -25,11 +32,14 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos import androidx.compose.runtime.withFrameNanos
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.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.painter.ColorPainter import androidx.compose.ui.graphics.painter.ColorPainter
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.KeyEventType
@@ -82,75 +92,56 @@ internal fun TvPlayerQueuePanel(
shape = RoundedCornerShape(24.dp), shape = RoundedCornerShape(24.dp),
color = scheme.surface.copy(alpha = 0.94f) color = scheme.surface.copy(alpha = 0.94f)
) { ) {
Column( if (uiState.queue.isEmpty()) {
modifier = Modifier.padding(horizontal = 22.dp, vertical = 20.dp), Text(
verticalArrangement = Arrangement.spacedBy(16.dp) text = "Add something to the queue to browse it here.",
) { color = scheme.onSurfaceVariant,
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { style = MaterialTheme.typography.bodyMedium
Text( )
text = "Playlist", } else {
color = scheme.onSurface, LazyRow(
style = MaterialTheme.typography.titleLarge, modifier = Modifier
fontWeight = FontWeight.Bold .fillMaxWidth()
) .testTag(TvPlayerPlaylistRowTag),
Text( state = listState,
text = queueCountLabel, horizontalArrangement = Arrangement.spacedBy(14.dp),
color = scheme.onSurfaceVariant, contentPadding = PaddingValues(16.dp)
style = MaterialTheme.typography.bodyMedium ) {
) itemsIndexed(uiState.queue, key = { _, item -> item.id }) { index, item ->
} val isEntryItem = index == entryIndex
TvQueueRowCard(
if (uiState.queue.isEmpty()) { item = item,
Text( isCurrent = item.isCurrent,
text = "Add something to the queue to browse it here.", isFirst = index == 0,
color = scheme.onSurfaceVariant, isLast = index == uiState.queue.lastIndex,
style = MaterialTheme.typography.bodyMedium onClick = { onSelect(item.id) },
) onReturnToControls = onReturnToControls,
} else { modifier = Modifier
LazyRow( .width(228.dp)
modifier = Modifier .then(
.fillMaxWidth() if (isEntryItem) {
.testTag(TvPlayerPlaylistRowTag), Modifier
state = listState, .focusRequester(firstItemFocusRequester)
horizontalArrangement = Arrangement.spacedBy(14.dp), .testTag(TvPlayerPlaylistCurrentItemTag)
contentPadding = PaddingValues(end = 4.dp) } else {
) { Modifier
itemsIndexed(uiState.queue, key = { _, item -> item.id }) { index, item -> }
val isEntryItem = index == entryIndex )
TvQueueRowCard( .then(
item = item, if (index == 0 && !isEntryItem) {
isCurrent = item.isCurrent, Modifier.testTag(TvPlayerPlaylistFirstItemTag)
isFirst = index == 0, } else {
isLast = index == uiState.queue.lastIndex, Modifier
onClick = { onSelect(item.id) }, }
onReturnToControls = onReturnToControls, )
modifier = Modifier .then(
.width(228.dp) if (index == uiState.queue.lastIndex && !isEntryItem) {
.then( Modifier.testTag(TvPlayerPlaylistLastItemTag)
if (isEntryItem) { } else {
Modifier Modifier
.focusRequester(firstItemFocusRequester) }
.testTag(TvPlayerPlaylistCurrentItemTag) )
} else { )
Modifier
}
)
.then(
if (index == 0 && !isEntryItem) {
Modifier.testTag(TvPlayerPlaylistFirstItemTag)
} else {
Modifier
}
)
.then(
if (index == uiState.queue.lastIndex && !isEntryItem) {
Modifier.testTag(TvPlayerPlaylistLastItemTag)
} else {
Modifier
}
)
)
}
} }
} }
} }
@@ -169,25 +160,49 @@ private fun TvQueueRowCard(
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
var isFocused by remember { mutableStateOf(false) } var isFocused by remember { mutableStateOf(false) }
val borderColor = when { val scale by animateFloatAsState(
isFocused -> scheme.primary targetValue = if (isFocused) 1.06f else 1f,
isCurrent -> scheme.primary.copy(alpha = 0.55f) label = "queueCardScale"
else -> scheme.outlineVariant.copy(alpha = 0.35f) )
} val borderColor by animateColorAsState(
val backgroundColor = when { targetValue = when {
isFocused -> scheme.primary.copy(alpha = 0.18f) isFocused -> scheme.primary
isCurrent -> scheme.surfaceVariant.copy(alpha = 0.78f) isCurrent -> scheme.tertiary
else -> scheme.surfaceVariant.copy(alpha = 0.62f) else -> scheme.outlineVariant.copy(alpha = 0.5f)
},
label = "queueCardBorder"
)
val backgroundColor by animateColorAsState(
targetValue = when {
isFocused -> scheme.primaryContainer.copy(alpha = 0.96f)
isCurrent -> scheme.tertiaryContainer.copy(alpha = 0.86f)
else -> scheme.surfaceContainerHigh.copy(alpha = 0.72f)
},
label = "queueCardBackground"
)
val contentColor = when {
isFocused -> scheme.onPrimaryContainer
isCurrent -> scheme.onTertiaryContainer
else -> scheme.onSurface
} }
val shape = RoundedCornerShape(18.dp)
Column( Column(
modifier = modifier modifier = modifier
.graphicsLayer {
scaleX = scale
scaleY = scale
}
.border( .border(
width = if (isFocused) 2.dp else 1.dp, width = when {
isFocused -> 3.dp
isCurrent -> 2.dp
else -> 1.dp
},
color = borderColor, color = borderColor,
shape = RoundedCornerShape(18.dp) shape = shape
) )
.clip(RoundedCornerShape(18.dp)) .clip(shape)
.background(backgroundColor) .background(backgroundColor)
.onFocusChanged { isFocused = it.isFocused } .onFocusChanged { isFocused = it.isFocused }
.onPreviewKeyEvent { event -> .onPreviewKeyEvent { event ->
@@ -207,37 +222,34 @@ private fun TvQueueRowCard(
} }
} }
.clickable { onClick() } .clickable { onClick() }
.padding(12.dp), .padding(4.dp),
verticalArrangement = Arrangement.spacedBy(10.dp) verticalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
TvQueueArtwork( TvQueueArtwork(
artworkUrl = item.artworkUrl, artworkUrl = item.artworkUrl,
isCurrent = isCurrent,
badgeColor = if (isFocused) scheme.primary else scheme.tertiary,
badgeContentColor = if (isFocused) scheme.onPrimary else scheme.onTertiary,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Text(
if (isCurrent) { text = item.title,
Text( color = contentColor,
text = "Current", style = MaterialTheme.typography.bodyMedium,
color = scheme.primary, fontWeight = if (isCurrent || isFocused) FontWeight.Bold else FontWeight.Medium,
style = MaterialTheme.typography.labelMedium, maxLines = 1,
fontWeight = FontWeight.SemiBold overflow = TextOverflow.Ellipsis,
) modifier = Modifier.padding(horizontal = 8.dp)
} )
Text(
text = item.title,
color = scheme.onSurface,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
} }
} }
@Composable @Composable
private fun TvQueueArtwork( private fun TvQueueArtwork(
artworkUrl: String?, artworkUrl: String?,
isCurrent: Boolean,
badgeColor: Color,
badgeContentColor: Color,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
@@ -268,5 +280,30 @@ private fun TvQueueArtwork(
.background(scheme.surfaceContainerHigh) .background(scheme.surfaceContainerHigh)
) )
} }
if (isCurrent) {
Row(
modifier = Modifier
.align(Alignment.TopStart)
.padding(8.dp)
.clip(RoundedCornerShape(50))
.background(badgeColor)
.padding(horizontal = 9.dp, vertical = 5.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Outlined.PlayArrow,
contentDescription = null,
tint = badgeContentColor,
modifier = Modifier.size(14.dp)
)
Text(
text = "Now playing",
color = badgeContentColor,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold
)
}
}
} }
} }

View File

@@ -1,25 +1,30 @@
package hu.bbara.purefin.ui.screen.player.components package hu.bbara.purefin.ui.screen.player.components
import androidx.compose.animation.animateColorAsState
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
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
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.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Check
import androidx.compose.material.icons.outlined.ClosedCaption import androidx.compose.material.icons.outlined.ClosedCaption
import androidx.compose.material.icons.outlined.HighQuality import androidx.compose.material.icons.outlined.HighQuality
import androidx.compose.material.icons.outlined.Language import androidx.compose.material.icons.outlined.Language
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -211,23 +216,42 @@ private fun TvTrackOptionRow(
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
var isFocused by remember { mutableStateOf(false) } var isFocused by remember { mutableStateOf(false) }
val backgroundColor by animateColorAsState(
targetValue = when {
isFocused -> scheme.primaryContainer
selected -> scheme.secondaryContainer.copy(alpha = 0.9f)
else -> scheme.surfaceContainerHigh.copy(alpha = 0.72f)
},
label = "trackOptionBackground"
)
val contentColor by animateColorAsState(
targetValue = when {
isFocused -> scheme.onPrimaryContainer
selected -> scheme.onSecondaryContainer
else -> scheme.onSurface
},
label = "trackOptionContent"
)
val borderColor by animateColorAsState(
targetValue = when {
isFocused -> scheme.primary
selected -> scheme.secondary
else -> scheme.outlineVariant.copy(alpha = 0.45f)
},
label = "trackOptionBorder"
)
val shape = RoundedCornerShape(12.dp)
Box( Box(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.then( .border(
if (isFocused) { width = if (isFocused) 3.dp else if (selected) 2.dp else 1.dp,
Modifier.border(2.dp, scheme.primary, RoundedCornerShape(12.dp)) color = borderColor,
} else { shape = shape
Modifier
}
)
.clip(RoundedCornerShape(12.dp))
.background(
if (isFocused) scheme.primary.copy(alpha = 0.3f)
else if (selected) scheme.primary.copy(alpha = 0.15f)
else scheme.surfaceVariant.copy(alpha = 0.6f)
) )
.clip(shape)
.background(backgroundColor)
.onFocusChanged { isFocused = it.isFocused } .onFocusChanged { isFocused = it.isFocused }
.onPreviewKeyEvent { event -> .onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) { if (event.type != KeyEventType.KeyDown) {
@@ -244,11 +268,25 @@ private fun TvTrackOptionRow(
.clickable { onClick() } .clickable { onClick() }
.padding(horizontal = 20.dp, vertical = 14.dp) .padding(horizontal = 20.dp, vertical = 14.dp)
) { ) {
Text( Row(
text = label, modifier = Modifier.fillMaxWidth(),
color = scheme.onSurface, horizontalArrangement = Arrangement.SpaceBetween,
style = MaterialTheme.typography.bodyLarge, verticalAlignment = Alignment.CenterVertically
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal ) {
) Text(
text = label,
color = contentColor,
style = MaterialTheme.typography.bodyLarge,
fontWeight = if (selected || isFocused) FontWeight.SemiBold else FontWeight.Normal
)
if (selected) {
Icon(
imageVector = Icons.Outlined.Check,
contentDescription = null,
tint = contentColor,
modifier = Modifier.size(22.dp)
)
}
}
} }
} }