1) Горизонтальный свайп по области списка переключает фильтры-чипы в их порядке ([Все]+жанры), выбранный чип автоскроллится в зону видимости. Вертикальная прокрутка грида сохраняется. 2) У играющей станции в списке — мягкое радиальное свечение позади обложки, которое «гуляет» (двигается центр) и вылезает из-под краёв, + эквалайзер- бейдж в углу. Источник активной станции — PlayerController.currentStationId. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
295 lines
12 KiB
Kotlin
295 lines
12 KiB
Kotlin
package com.radiola.ui.components
|
||
|
||
import androidx.compose.animation.animateColorAsState
|
||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||
import androidx.compose.animation.core.LinearEasing
|
||
import androidx.compose.animation.core.RepeatMode
|
||
import androidx.compose.animation.core.animateFloat
|
||
import androidx.compose.animation.core.infiniteRepeatable
|
||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||
import androidx.compose.animation.core.tween
|
||
import androidx.compose.foundation.background
|
||
import androidx.compose.foundation.clickable
|
||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||
import androidx.compose.foundation.layout.*
|
||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||
import androidx.compose.material3.Icon
|
||
import androidx.compose.material3.Text
|
||
import androidx.compose.runtime.Composable
|
||
import androidx.compose.runtime.getValue
|
||
import androidx.compose.runtime.remember
|
||
import androidx.compose.ui.Alignment
|
||
import androidx.compose.ui.Modifier
|
||
import androidx.compose.ui.draw.BlurredEdgeTreatment
|
||
import androidx.compose.ui.draw.blur
|
||
import androidx.compose.ui.draw.clip
|
||
import androidx.compose.ui.draw.drawBehind
|
||
import androidx.compose.ui.draw.scale
|
||
import androidx.compose.ui.geometry.CornerRadius
|
||
import androidx.compose.ui.geometry.Offset
|
||
import androidx.compose.ui.graphics.Brush
|
||
import androidx.compose.ui.graphics.Color
|
||
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
|
||
import com.composables.icons.lucide.Heart
|
||
import com.composables.icons.lucide.Lucide
|
||
import com.radiola.domain.model.Station
|
||
import com.radiola.domain.model.Track
|
||
import com.radiola.ui.theme.Motion
|
||
import com.radiola.ui.theme.RadiolaTheme
|
||
import com.radiola.ui.theme.pressScale
|
||
|
||
@Composable
|
||
fun StationCard(
|
||
station: Station,
|
||
isFavorite: Boolean,
|
||
onClick: () -> Unit,
|
||
onFavoriteClick: () -> Unit,
|
||
modifier: Modifier = Modifier,
|
||
nowTrack: Track? = null,
|
||
isCurrent: Boolean = false,
|
||
isPlaying: Boolean = false
|
||
) {
|
||
val colors = RadiolaTheme.colors
|
||
val haptics = LocalHapticFeedback.current
|
||
val interaction = remember { MutableInteractionSource() }
|
||
val heartTint by animateColorAsState(
|
||
targetValue = if (isFavorite) colors.accent else colors.textPrimary,
|
||
animationSpec = tween(Motion.Medium),
|
||
label = "heartTint"
|
||
)
|
||
|
||
Column(
|
||
modifier = modifier
|
||
.pressScale(interactionSource = interaction)
|
||
.clickable(interactionSource = interaction, indication = null, onClick = onClick)
|
||
) {
|
||
Box(
|
||
modifier = Modifier
|
||
.fillMaxWidth()
|
||
.aspectRatio(1f),
|
||
contentAlignment = Alignment.Center
|
||
) {
|
||
// Свечение активной станции — позади обложки, мягко вылезает из-под краёв.
|
||
if (isCurrent) {
|
||
PlayingGlow(
|
||
modifier = Modifier.matchParentSize(),
|
||
color = colors.accent,
|
||
playing = isPlaying
|
||
)
|
||
}
|
||
Box(
|
||
modifier = Modifier
|
||
.matchParentSize()
|
||
.clip(RoundedCornerShape(16.dp))
|
||
.background(colors.surface2)
|
||
) {
|
||
val trackCover = nowTrack?.coverUrl?.takeIf { it.isNotBlank() }
|
||
// Фон карточки: обложка трека → логотип станции → фирменная плитка.
|
||
when {
|
||
trackCover != null -> {
|
||
AsyncImage(
|
||
model = crossfadeModel(trackCover),
|
||
contentDescription = nowTrack.song,
|
||
modifier = Modifier.fillMaxSize(),
|
||
contentScale = ContentScale.Crop
|
||
)
|
||
}
|
||
!station.coverUrl.isNullOrBlank() -> {
|
||
AsyncImage(
|
||
model = crossfadeModel(station.coverUrl),
|
||
contentDescription = station.name,
|
||
modifier = Modifier.fillMaxSize(),
|
||
contentScale = ContentScale.Crop
|
||
)
|
||
}
|
||
else -> {
|
||
Box(
|
||
modifier = Modifier
|
||
.fillMaxSize()
|
||
.background(stationTileBrush(station.name)),
|
||
contentAlignment = Alignment.Center
|
||
) {
|
||
Text(
|
||
text = stationInitials(station.name),
|
||
color = Color.White,
|
||
fontWeight = FontWeight.Black,
|
||
style = androidx.compose.material3.MaterialTheme.typography.headlineMedium
|
||
)
|
||
}
|
||
}
|
||
}
|
||
// Подпись играющего трека — поверх любого фона, если трек известен.
|
||
if (nowTrack != null) {
|
||
Box(
|
||
modifier = Modifier
|
||
.fillMaxSize()
|
||
.background(
|
||
Brush.verticalGradient(
|
||
0f to Color.Transparent,
|
||
0.5f to Color.Transparent,
|
||
1f to Color.Black.copy(alpha = 0.8f)
|
||
)
|
||
)
|
||
)
|
||
Column(
|
||
modifier = Modifier
|
||
.align(Alignment.BottomStart)
|
||
.padding(10.dp)
|
||
) {
|
||
Text(
|
||
text = nowTrack.song,
|
||
color = Color.White,
|
||
fontWeight = FontWeight.Bold,
|
||
style = androidx.compose.material3.MaterialTheme.typography.bodyMedium,
|
||
maxLines = 1,
|
||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis
|
||
)
|
||
Text(
|
||
text = nowTrack.artist,
|
||
color = Color.White.copy(alpha = 0.8f),
|
||
style = androidx.compose.material3.MaterialTheme.typography.labelMedium,
|
||
maxLines = 1,
|
||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis
|
||
)
|
||
}
|
||
}
|
||
// Бейдж активной станции: эквалайзер в углу обложки.
|
||
if (isCurrent) {
|
||
Box(
|
||
modifier = Modifier
|
||
.align(Alignment.TopStart)
|
||
.padding(10.dp)
|
||
.clip(RoundedCornerShape(8.dp))
|
||
.background(Color.Black.copy(alpha = 0.45f))
|
||
.padding(horizontal = 7.dp, vertical = 6.dp),
|
||
contentAlignment = Alignment.Center
|
||
) {
|
||
com.radiola.ui.theme.LiveEqualizer(
|
||
modifier = Modifier.size(width = 16.dp, height = 12.dp),
|
||
barCount = 4,
|
||
color = colors.accent,
|
||
playing = isPlaying
|
||
)
|
||
}
|
||
}
|
||
// Кнопка сердечка — поверх всего, top-end.
|
||
Box(
|
||
modifier = Modifier
|
||
.align(Alignment.TopEnd)
|
||
.padding(10.dp)
|
||
.size(32.dp)
|
||
.clip(RoundedCornerShape(16.dp))
|
||
.background(Color.Black.copy(alpha = 0.4f))
|
||
.clickable {
|
||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||
onFavoriteClick()
|
||
},
|
||
contentAlignment = Alignment.Center
|
||
) {
|
||
Icon(
|
||
imageVector = Lucide.Heart,
|
||
contentDescription = if (isFavorite) "В избранном" else "В избранное",
|
||
tint = heartTint,
|
||
modifier = Modifier.size(17.dp)
|
||
)
|
||
}
|
||
}
|
||
}
|
||
Spacer(Modifier.height(10.dp))
|
||
Text(
|
||
text = station.name,
|
||
style = androidx.compose.material3.MaterialTheme.typography.titleMedium,
|
||
color = colors.textPrimary,
|
||
maxLines = 1,
|
||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis
|
||
)
|
||
if (station.genre.isNotBlank()) {
|
||
Text(
|
||
text = station.genre,
|
||
style = androidx.compose.material3.MaterialTheme.typography.labelMedium,
|
||
color = colors.textSecondary,
|
||
maxLines = 1,
|
||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||
fontWeight = FontWeight.Normal
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Мягкое радиальное свечение играющей станции — рисуется ПОЗАДИ обложки и
|
||
* вылезает из-под её краёв. Центр градиента «гуляет», размер дышит.
|
||
*/
|
||
@Composable
|
||
private fun PlayingGlow(
|
||
modifier: Modifier = Modifier,
|
||
color: Color,
|
||
playing: Boolean
|
||
) {
|
||
val transition = rememberInfiniteTransition(label = "glow")
|
||
val t by transition.animateFloat(
|
||
initialValue = 0f,
|
||
targetValue = (2f * Math.PI).toFloat(),
|
||
animationSpec = infiniteRepeatable(
|
||
animation = tween(4200, easing = LinearEasing),
|
||
repeatMode = RepeatMode.Restart
|
||
),
|
||
label = "glowT"
|
||
)
|
||
val pulse by transition.animateFloat(
|
||
initialValue = if (playing) 1.05f else 1.0f,
|
||
targetValue = if (playing) 1.20f else 1.07f,
|
||
animationSpec = infiniteRepeatable(
|
||
animation = tween(2200, easing = FastOutSlowInEasing),
|
||
repeatMode = RepeatMode.Reverse
|
||
),
|
||
label = "glowPulse"
|
||
)
|
||
val cx = 0.5f + 0.22f * kotlin.math.cos(t)
|
||
val cy = 0.5f + 0.22f * kotlin.math.sin(t * 1.3f)
|
||
Box(
|
||
modifier = modifier
|
||
.scale(pulse)
|
||
.blur(28.dp, BlurredEdgeTreatment.Unbounded)
|
||
.drawBehind {
|
||
val brush = Brush.radialGradient(
|
||
colors = listOf(
|
||
color.copy(alpha = 0.85f),
|
||
color.copy(alpha = 0.35f),
|
||
Color.Transparent
|
||
),
|
||
center = Offset(size.width * cx, size.height * cy),
|
||
radius = size.minDimension * 0.72f
|
||
)
|
||
drawRoundRect(
|
||
brush = brush,
|
||
cornerRadius = CornerRadius(22.dp.toPx(), 22.dp.toPx())
|
||
)
|
||
}
|
||
)
|
||
}
|
||
|
||
/** Инициалы станции для плитки-плейсхолдера (1–2 символа). */
|
||
private fun stationInitials(name: String): String {
|
||
val words = name.trim().split(Regex("\\s+")).filter { it.isNotBlank() }
|
||
return when {
|
||
words.isEmpty() -> "?"
|
||
words.size == 1 -> words[0].take(2).uppercase()
|
||
else -> (words[0].take(1) + words[1].take(1)).uppercase()
|
||
}
|
||
}
|
||
|
||
/** Детерминированный фирменный градиент плитки по названию станции. */
|
||
private fun stationTileBrush(name: String): Brush {
|
||
val h = (name.hashCode().toLong() and 0xFFFFFFFFL)
|
||
val hue = (h % 360L).toFloat()
|
||
val c1 = Color.hsv(hue, 0.55f, 0.45f)
|
||
val c2 = Color.hsv((hue + 28f) % 360f, 0.6f, 0.30f)
|
||
return Brush.linearGradient(listOf(c1, c2))
|
||
}
|