Files
radiola-android/app/src/main/java/com/radiola/ui/components/StationCard.kt
nk 9268e14cc6 feat(stations): свайп по списку листает чипы + свечение играющей станции
1) Горизонтальный свайп по области списка переключает фильтры-чипы в их
   порядке ([Все]+жанры), выбранный чип автоскроллится в зону видимости.
   Вертикальная прокрутка грида сохраняется.

2) У играющей станции в списке — мягкое радиальное свечение позади обложки,
   которое «гуляет» (двигается центр) и вылезает из-под краёв, + эквалайзер-
   бейдж в углу. Источник активной станции — PlayerController.currentStationId.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 14:12:01 +03:00

295 lines
12 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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())
)
}
)
}
/** Инициалы станции для плитки-плейсхолдера (12 символа). */
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))
}