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 8a951dd4c5
commit a3f3494da2
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.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.border
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.pressScale
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MiniPlayer(
stationName: String,
@@ -39,6 +44,7 @@ fun MiniPlayer(
modifier: Modifier = Modifier
) {
val colors = RadiolaTheme.colors
val haptics = LocalHapticFeedback.current
Row(
modifier = modifier
.fillMaxWidth()
@@ -59,7 +65,7 @@ fun MiniPlayer(
) {
if (track?.coverUrl != null) {
AsyncImage(
model = track.coverUrl,
model = crossfadeModel(track.coverUrl),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
@@ -83,7 +89,7 @@ fun MiniPlayer(
style = androidx.compose.material3.MaterialTheme.typography.titleMedium,
color = colors.textPrimary,
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis
modifier = Modifier.basicMarquee()
)
}
Spacer(Modifier.width(8.dp))
@@ -95,9 +101,11 @@ fun MiniPlayer(
.background(colors.accent)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onPlayPause
),
indication = null
) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onPlayPause()
},
contentAlignment = Alignment.Center
) {
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.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
@@ -36,6 +38,7 @@ fun StationCard(
modifier: Modifier = Modifier
) {
val colors = RadiolaTheme.colors
val haptics = LocalHapticFeedback.current
val interaction = remember { MutableInteractionSource() }
val heartTint by animateColorAsState(
targetValue = if (isFavorite) colors.accent else colors.textPrimary,
@@ -57,7 +60,7 @@ fun StationCard(
) {
if (!station.coverUrl.isNullOrBlank()) {
AsyncImage(
model = station.coverUrl,
model = crossfadeModel(station.coverUrl),
contentDescription = station.name,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
@@ -77,7 +80,10 @@ fun StationCard(
.size(32.dp)
.clip(RoundedCornerShape(16.dp))
.background(androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.4f))
.clickable(onClick = onFavoriteClick),
.clickable {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onFavoriteClick()
},
contentAlignment = Alignment.Center
) {
Icon(

View File

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

View File

@@ -1,6 +1,8 @@
package com.radiola.ui.player
import android.util.Log
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.basicMarquee
import androidx.compose.animation.Crossfade
import androidx.compose.animation.animateColorAsState
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.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
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.pressScale
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun PlayerBottomSheet(
station: Station?,
@@ -65,6 +70,7 @@ fun PlayerBottomSheet(
val context = LocalContext.current
val enabledServices by viewModel.enabledServices.collectAsState()
val colors = RadiolaTheme.colors
val haptics = LocalHapticFeedback.current
Column(
modifier = modifier
@@ -95,7 +101,7 @@ fun PlayerBottomSheet(
val coverModel = track?.coverUrl ?: station?.coverUrl
if (!coverModel.isNullOrBlank()) {
AsyncImage(
model = coverModel,
model = com.radiola.ui.components.crossfadeModel(coverModel),
contentDescription = station?.name,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
@@ -123,8 +129,8 @@ fun PlayerBottomSheet(
style = MaterialTheme.typography.headlineLarge,
color = colors.textPrimary,
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))
Text(
@@ -161,7 +167,13 @@ fun PlayerBottomSheet(
label = "heartTint"
)
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))
}
}
@@ -181,7 +193,10 @@ fun PlayerBottomSheet(
.clip(CircleShape)
.background(colors.accent)
.pressScale(interactionSource = playInteraction)
.clickable(interactionSource = playInteraction, indication = null, onClick = onPlayPause),
.clickable(interactionSource = playInteraction, indication = null) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onPlayPause()
},
contentAlignment = Alignment.Center
) {
Crossfade(