polish(ui): плавные обложки, бегущая строка длинных названий, тактильный отклик

- Coil crossfade для всех обложек (Images.crossfadeModel) — без «моргания» при загрузке
- basicMarquee для длинных названий трека (плеер и мини-плеер) вместо обрезки
- haptic feedback на play/pause и добавление в избранное (плеер, мини-плеер, карточка)
This commit is contained in:
nk
2026-06-02 22:33:33 +03:00
parent 310d6c3177
commit a4af72a6e6
5 changed files with 58 additions and 13 deletions

View File

@@ -0,0 +1,16 @@
package com.radiola.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import coil.request.ImageRequest
/**
* Модель изображения с плавным проявлением (crossfade).
* Используется во всех обложках, чтобы загрузка не «моргала».
*/
@Composable
fun crossfadeModel(url: String?): ImageRequest =
ImageRequest.Builder(LocalContext.current)
.data(url)
.crossfade(220)
.build()

View File

@@ -2,6 +2,10 @@ package com.radiola.ui.components
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.basicMarquee
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
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
@@ -29,6 +33,7 @@ import com.radiola.ui.theme.Motion
import com.radiola.ui.theme.RadiolaTheme import com.radiola.ui.theme.RadiolaTheme
import com.radiola.ui.theme.pressScale import com.radiola.ui.theme.pressScale
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun MiniPlayer( fun MiniPlayer(
stationName: String, stationName: String,
@@ -39,6 +44,7 @@ fun MiniPlayer(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val colors = RadiolaTheme.colors val colors = RadiolaTheme.colors
val haptics = LocalHapticFeedback.current
Row( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
@@ -59,7 +65,7 @@ fun MiniPlayer(
) { ) {
if (track?.coverUrl != null) { if (track?.coverUrl != null) {
AsyncImage( AsyncImage(
model = track.coverUrl, model = crossfadeModel(track.coverUrl),
contentDescription = null, contentDescription = null,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
@@ -83,7 +89,7 @@ fun MiniPlayer(
style = androidx.compose.material3.MaterialTheme.typography.titleMedium, style = androidx.compose.material3.MaterialTheme.typography.titleMedium,
color = colors.textPrimary, color = colors.textPrimary,
maxLines = 1, maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis modifier = Modifier.basicMarquee()
) )
} }
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
@@ -95,9 +101,11 @@ fun MiniPlayer(
.background(colors.accent) .background(colors.accent)
.clickable( .clickable(
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
indication = null, indication = null
onClick = onPlayPause ) {
), haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onPlayPause()
},
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Crossfade(targetState = isPlaying, animationSpec = tween(Motion.Fast), label = "miniPlay") { playing -> Crossfade(targetState = isPlaying, animationSpec = tween(Motion.Fast), label = "miniPlay") { playing ->

View File

@@ -15,7 +15,9 @@ import androidx.compose.runtime.remember
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.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalHapticFeedback
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 coil.compose.AsyncImage import coil.compose.AsyncImage
@@ -36,6 +38,7 @@ fun StationCard(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val colors = RadiolaTheme.colors val colors = RadiolaTheme.colors
val haptics = LocalHapticFeedback.current
val interaction = remember { MutableInteractionSource() } val interaction = remember { MutableInteractionSource() }
val heartTint by animateColorAsState( val heartTint by animateColorAsState(
targetValue = if (isFavorite) colors.accent else colors.textPrimary, targetValue = if (isFavorite) colors.accent else colors.textPrimary,
@@ -57,7 +60,7 @@ fun StationCard(
) { ) {
if (!station.coverUrl.isNullOrBlank()) { if (!station.coverUrl.isNullOrBlank()) {
AsyncImage( AsyncImage(
model = station.coverUrl, model = crossfadeModel(station.coverUrl),
contentDescription = station.name, contentDescription = station.name,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
@@ -77,7 +80,10 @@ fun StationCard(
.size(32.dp) .size(32.dp)
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(16.dp))
.background(androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.4f)) .background(androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.4f))
.clickable(onClick = onFavoriteClick), .clickable {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onFavoriteClick()
},
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( Icon(

View File

@@ -51,7 +51,7 @@ fun TrackListItem(
) { ) {
if (!track.coverUrl.isNullOrBlank()) { if (!track.coverUrl.isNullOrBlank()) {
AsyncImage( AsyncImage(
model = track.coverUrl, model = crossfadeModel(track.coverUrl),
contentDescription = null, contentDescription = null,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop

View File

@@ -1,6 +1,8 @@
package com.radiola.ui.player package com.radiola.ui.player
import android.util.Log import android.util.Log
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.basicMarquee
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
@@ -20,7 +22,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
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
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
@@ -47,6 +51,7 @@ import com.radiola.ui.theme.Motion
import com.radiola.ui.theme.RadiolaTheme import com.radiola.ui.theme.RadiolaTheme
import com.radiola.ui.theme.pressScale import com.radiola.ui.theme.pressScale
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun PlayerBottomSheet( fun PlayerBottomSheet(
station: Station?, station: Station?,
@@ -65,6 +70,7 @@ fun PlayerBottomSheet(
val context = LocalContext.current val context = LocalContext.current
val enabledServices by viewModel.enabledServices.collectAsState() val enabledServices by viewModel.enabledServices.collectAsState()
val colors = RadiolaTheme.colors val colors = RadiolaTheme.colors
val haptics = LocalHapticFeedback.current
Column( Column(
modifier = modifier modifier = modifier
@@ -95,7 +101,7 @@ fun PlayerBottomSheet(
val coverModel = track?.coverUrl ?: station?.coverUrl val coverModel = track?.coverUrl ?: station?.coverUrl
if (!coverModel.isNullOrBlank()) { if (!coverModel.isNullOrBlank()) {
AsyncImage( AsyncImage(
model = coverModel, model = com.radiola.ui.components.crossfadeModel(coverModel),
contentDescription = station?.name, contentDescription = station?.name,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
@@ -123,8 +129,8 @@ fun PlayerBottomSheet(
style = MaterialTheme.typography.headlineLarge, style = MaterialTheme.typography.headlineLarge,
color = colors.textPrimary, color = colors.textPrimary,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, textAlign = androidx.compose.ui.text.style.TextAlign.Center,
textAlign = androidx.compose.ui.text.style.TextAlign.Center modifier = Modifier.basicMarquee()
) )
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Text( Text(
@@ -161,7 +167,13 @@ fun PlayerBottomSheet(
label = "heartTint" label = "heartTint"
) )
PlayerIconBtn(size = 44.dp) { PlayerIconBtn(size = 44.dp) {
IconButton(onClick = onToggleFavorite, modifier = Modifier.size(44.dp)) { IconButton(
onClick = {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onToggleFavorite()
},
modifier = Modifier.size(44.dp)
) {
Icon(Lucide.Heart, "Избранное", tint = heartTint, modifier = Modifier.size(22.dp)) Icon(Lucide.Heart, "Избранное", tint = heartTint, modifier = Modifier.size(22.dp))
} }
} }
@@ -181,7 +193,10 @@ fun PlayerBottomSheet(
.clip(CircleShape) .clip(CircleShape)
.background(colors.accent) .background(colors.accent)
.pressScale(interactionSource = playInteraction) .pressScale(interactionSource = playInteraction)
.clickable(interactionSource = playInteraction, indication = null, onClick = onPlayPause), .clickable(interactionSource = playInteraction, indication = null) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onPlayPause()
},
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Crossfade( Crossfade(