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.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