From 4e9d8c0b3bbcdb64f43c09e4f6e94766a5d2a2e6 Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Sat, 9 May 2026 08:49:49 +0200 Subject: [PATCH] feat(player): enhance UI responsiveness with animated color states for buttons and track selection --- .../screen/player/components/TvIconButton.kt | 23 +- .../player/components/TvPlayerQueuePanel.kt | 237 ++++++++++-------- .../components/TvTrackSelectionButtons.kt | 74 ++++-- 3 files changed, 209 insertions(+), 125 deletions(-) diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/components/TvIconButton.kt b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/components/TvIconButton.kt index 608d7c95..acf86fec 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/components/TvIconButton.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/components/TvIconButton.kt @@ -54,6 +54,18 @@ internal fun TvIconButton( targetValue = if (isFocused) scheme.primary else Color.Transparent, 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 iconSize = (size - 24).coerceAtLeast(0).dp @@ -69,10 +81,7 @@ internal fun TvIconButton( shape = shape ) .clip(shape) - .background( - if (isFocused) scheme.primary.copy(alpha = 0.5f) - else scheme.background.copy(alpha = 0.65f) - ) + .background(backgroundColor) .focusProperties { canFocus = enabled } .semantics { if (!enabled) { @@ -86,7 +95,7 @@ internal fun TvIconButton( Icon( imageVector = icon, contentDescription = contentDescription, - tint = scheme.onBackground, + tint = contentColor, modifier = buttonModifier .size(size.dp) .padding(12.dp) @@ -104,12 +113,12 @@ internal fun TvIconButton( Icon( imageVector = icon, contentDescription = contentDescription, - tint = scheme.onBackground, + tint = contentColor, modifier = Modifier.size(iconSize) ) Text( text = label, - color = scheme.onBackground, + color = contentColor, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold ) diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/components/TvPlayerQueuePanel.kt b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/components/TvPlayerQueuePanel.kt index a56e734e..b6b07ae2 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/components/TvPlayerQueuePanel.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/components/TvPlayerQueuePanel.kt @@ -1,5 +1,7 @@ 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.border 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.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState 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.Surface import androidx.compose.material3.Text @@ -25,11 +32,14 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester 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.input.key.Key import androidx.compose.ui.input.key.KeyEventType @@ -82,75 +92,56 @@ internal fun TvPlayerQueuePanel( shape = RoundedCornerShape(24.dp), color = scheme.surface.copy(alpha = 0.94f) ) { - Column( - modifier = Modifier.padding(horizontal = 22.dp, vertical = 20.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text( - text = "Playlist", - color = scheme.onSurface, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold - ) - Text( - text = queueCountLabel, - color = scheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium - ) - } - - if (uiState.queue.isEmpty()) { - Text( - text = "Add something to the queue to browse it here.", - color = scheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium - ) - } else { - LazyRow( - modifier = Modifier - .fillMaxWidth() - .testTag(TvPlayerPlaylistRowTag), - state = listState, - horizontalArrangement = Arrangement.spacedBy(14.dp), - contentPadding = PaddingValues(end = 4.dp) - ) { - itemsIndexed(uiState.queue, key = { _, item -> item.id }) { index, item -> - val isEntryItem = index == entryIndex - TvQueueRowCard( - item = item, - isCurrent = item.isCurrent, - isFirst = index == 0, - isLast = index == uiState.queue.lastIndex, - onClick = { onSelect(item.id) }, - onReturnToControls = onReturnToControls, - modifier = Modifier - .width(228.dp) - .then( - if (isEntryItem) { - 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 - } - ) - ) - } + if (uiState.queue.isEmpty()) { + Text( + text = "Add something to the queue to browse it here.", + color = scheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium + ) + } else { + LazyRow( + modifier = Modifier + .fillMaxWidth() + .testTag(TvPlayerPlaylistRowTag), + state = listState, + horizontalArrangement = Arrangement.spacedBy(14.dp), + contentPadding = PaddingValues(16.dp) + ) { + itemsIndexed(uiState.queue, key = { _, item -> item.id }) { index, item -> + val isEntryItem = index == entryIndex + TvQueueRowCard( + item = item, + isCurrent = item.isCurrent, + isFirst = index == 0, + isLast = index == uiState.queue.lastIndex, + onClick = { onSelect(item.id) }, + onReturnToControls = onReturnToControls, + modifier = Modifier + .width(228.dp) + .then( + if (isEntryItem) { + 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 var isFocused by remember { mutableStateOf(false) } - val borderColor = when { - isFocused -> scheme.primary - isCurrent -> scheme.primary.copy(alpha = 0.55f) - else -> scheme.outlineVariant.copy(alpha = 0.35f) - } - val backgroundColor = when { - isFocused -> scheme.primary.copy(alpha = 0.18f) - isCurrent -> scheme.surfaceVariant.copy(alpha = 0.78f) - else -> scheme.surfaceVariant.copy(alpha = 0.62f) + val scale by animateFloatAsState( + targetValue = if (isFocused) 1.06f else 1f, + label = "queueCardScale" + ) + val borderColor by animateColorAsState( + targetValue = when { + isFocused -> scheme.primary + isCurrent -> scheme.tertiary + 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( modifier = modifier + .graphicsLayer { + scaleX = scale + scaleY = scale + } .border( - width = if (isFocused) 2.dp else 1.dp, + width = when { + isFocused -> 3.dp + isCurrent -> 2.dp + else -> 1.dp + }, color = borderColor, - shape = RoundedCornerShape(18.dp) + shape = shape ) - .clip(RoundedCornerShape(18.dp)) + .clip(shape) .background(backgroundColor) .onFocusChanged { isFocused = it.isFocused } .onPreviewKeyEvent { event -> @@ -207,37 +222,34 @@ private fun TvQueueRowCard( } } .clickable { onClick() } - .padding(12.dp), + .padding(4.dp), verticalArrangement = Arrangement.spacedBy(10.dp) ) { TvQueueArtwork( artworkUrl = item.artworkUrl, + isCurrent = isCurrent, + badgeColor = if (isFocused) scheme.primary else scheme.tertiary, + badgeContentColor = if (isFocused) scheme.onPrimary else scheme.onTertiary, modifier = Modifier.fillMaxWidth() ) - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - if (isCurrent) { - Text( - text = "Current", - color = scheme.primary, - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold - ) - } - Text( - text = item.title, - color = scheme.onSurface, - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.SemiBold, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } + Text( + text = item.title, + color = contentColor, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (isCurrent || isFocused) FontWeight.Bold else FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 8.dp) + ) } } @Composable private fun TvQueueArtwork( artworkUrl: String?, + isCurrent: Boolean, + badgeColor: Color, + badgeContentColor: Color, modifier: Modifier = Modifier ) { val scheme = MaterialTheme.colorScheme @@ -268,5 +280,30 @@ private fun TvQueueArtwork( .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 + ) + } + } } } diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/components/TvTrackSelectionButtons.kt b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/components/TvTrackSelectionButtons.kt index f68ad62d..f27e8c5c 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/components/TvTrackSelectionButtons.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/components/TvTrackSelectionButtons.kt @@ -1,25 +1,30 @@ package hu.bbara.purefin.ui.screen.player.components +import androidx.compose.animation.animateColorAsState 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 import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape 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.HighQuality import androidx.compose.material.icons.outlined.Language +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -211,23 +216,42 @@ private fun TvTrackOptionRow( ) { val scheme = MaterialTheme.colorScheme 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( modifier = modifier .fillMaxWidth() - .then( - if (isFocused) { - Modifier.border(2.dp, scheme.primary, RoundedCornerShape(12.dp)) - } else { - 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) + .border( + width = if (isFocused) 3.dp else if (selected) 2.dp else 1.dp, + color = borderColor, + shape = shape ) + .clip(shape) + .background(backgroundColor) .onFocusChanged { isFocused = it.isFocused } .onPreviewKeyEvent { event -> if (event.type != KeyEventType.KeyDown) { @@ -244,11 +268,25 @@ private fun TvTrackOptionRow( .clickable { onClick() } .padding(horizontal = 20.dp, vertical = 14.dp) ) { - Text( - text = label, - color = scheme.onSurface, - style = MaterialTheme.typography.bodyLarge, - fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal - ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + 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) + ) + } + } } }