feat(stations): свайп по списку листает чипы + свечение играющей станции

1) Горизонтальный свайп по области списка переключает фильтры-чипы в их
   порядке ([Все]+жанры), выбранный чип автоскроллится в зону видимости.
   Вертикальная прокрутка грида сохраняется.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nk
2026-06-04 14:12:01 +03:00
parent 603e232dff
commit 9268e14cc6
6 changed files with 169 additions and 9 deletions

View File

@@ -1,6 +1,12 @@
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
@@ -14,7 +20,13 @@ 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
@@ -38,7 +50,9 @@ fun StationCard(
onClick: () -> Unit,
onFavoriteClick: () -> Unit,
modifier: Modifier = Modifier,
nowTrack: Track? = null
nowTrack: Track? = null,
isCurrent: Boolean = false,
isPlaying: Boolean = false
) {
val colors = RadiolaTheme.colors
val haptics = LocalHapticFeedback.current
@@ -57,10 +71,23 @@ fun StationCard(
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.clip(RoundedCornerShape(16.dp))
.background(colors.surface2)
.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 {
@@ -131,6 +158,25 @@ fun StationCard(
)
}
}
// Бейдж активной станции: эквалайзер в углу обложки.
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
@@ -152,6 +198,7 @@ fun StationCard(
modifier = Modifier.size(17.dp)
)
}
}
}
Spacer(Modifier.height(10.dp))
Text(
@@ -174,6 +221,59 @@ fun StationCard(
}
}
/**
* Мягкое радиальное свечение играющей станции — рисуется ПОЗАДИ обложки и
* вылезает из-под её краёв. Центр градиента «гуляет», размер дышит.
*/
@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() }