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

@@ -32,6 +32,10 @@ class PlayerController @Inject constructor(
private val _currentStationPrefix = MutableStateFlow<String?>(null)
val currentStationPrefix: StateFlow<String?> = _currentStationPrefix
// Id играющей станции — для подсветки активной карточки в списке.
private val _currentStationId = MutableStateFlow<Int?>(null)
val currentStationId: StateFlow<Int?> = _currentStationId
private val _icyTitle = MutableStateFlow<String?>(null)
val icyTitle: StateFlow<String?> = _icyTitle.asStateFlow()
@@ -136,8 +140,9 @@ class PlayerController @Inject constructor(
audioManager.registerAudioDeviceCallback(audioDeviceCallback, null)
}
fun play(url: String, stationPrefix: String, stationName: String) {
fun play(url: String, stationPrefix: String, stationName: String, stationId: Int? = null) {
Log.d("PlayerController", "play() called with url=$url prefix=$stationPrefix")
_currentStationId.value = stationId
_icyTitle.value = null
val mediaItem = MediaItem.Builder()
.setUri(url)
@@ -193,6 +198,7 @@ class PlayerController @Inject constructor(
fun stop() {
exoPlayer.stop()
_currentStationPrefix.value = null
_currentStationId.value = null
}
fun release() {

View File

@@ -9,9 +9,11 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.animation.animateColorAsState
@@ -31,8 +33,15 @@ fun FilterChips(
onTagSelected: (String?) -> Unit,
modifier: Modifier = Modifier
) {
val listState = rememberLazyListState()
// Доводим выбранный чип в зону видимости (важно при свайп-переключении).
val selectedIndex = if (selectedTag == null) 0 else tags.indexOf(selectedTag) + 1
LaunchedEffect(selectedIndex) {
if (selectedIndex >= 0) listState.animateScrollToItem(selectedIndex)
}
LazyRow(
modifier = modifier,
state = listState,
horizontalArrangement = Arrangement.spacedBy(9.dp),
contentPadding = PaddingValues(horizontal = 16.dp)
) {

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() }

View File

@@ -106,7 +106,7 @@ class PlayerViewModel @Inject constructor(
// Для остальных resolve вернёт URL как есть.
viewModelScope.launch {
val url = loveStreamResolver.resolve(streamUrl)
playerController.play(url, station.prefix, station.name)
playerController.play(url, station.prefix, station.name, station.id)
}
viewModelScope.launch { pushHistoryUseCase(station.id) }
nowPlayingJob?.cancel()

View File

@@ -1,6 +1,7 @@
package com.radiola.ui.stations
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
@@ -9,6 +10,9 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
@@ -35,7 +39,22 @@ fun StationsScreen(
val error by viewModel.error.collectAsState()
val favoriteIds by viewModel.favoriteIds.collectAsState()
val nowPlaying by viewModel.nowPlaying.collectAsState()
val playingStationId by viewModel.playingStationId.collectAsState()
val isPlaying by viewModel.isPlaying.collectAsState()
val colors = RadiolaTheme.colors
val haptics = LocalHapticFeedback.current
// Полный порядок фильтров: «Все» (null) + жанры. Свайп листает по нему.
val orderedTags = remember(tags) { listOf<String?>(null) + tags }
fun switchTag(forward: Boolean) {
if (orderedTags.size <= 1) return
val idx = orderedTags.indexOf(selectedTag).coerceAtLeast(0)
val newIdx = idx + if (forward) 1 else -1
if (newIdx in orderedTags.indices) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
viewModel.onTagSelected(orderedTags[newIdx])
}
}
Column(modifier = modifier.fillMaxSize()) {
// Двухцветный заголовок экрана
@@ -66,8 +85,26 @@ fun StationsScreen(
Spacer(Modifier.height(8.dp))
}
// Область результатов — единственная прокручиваемая зона
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
// Область результатов — единственная прокручиваемая зона.
// Горизонтальный свайп листает фильтры-чипы (вертикаль остаётся у грида).
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.pointerInput(orderedTags, selectedTag) {
var totalDx = 0f
detectHorizontalDragGestures(
onDragStart = { totalDx = 0f },
onDragEnd = {
val threshold = 56.dp.toPx()
when {
totalDx <= -threshold -> switchTag(forward = true)
totalDx >= threshold -> switchTag(forward = false)
}
}
) { _, dragAmount -> totalDx += dragAmount }
}
) {
when {
isLoading && stations.isEmpty() -> {
CircularProgressIndicator(
@@ -119,6 +156,8 @@ fun StationsScreen(
onClick = { onStationClick(station) },
onFavoriteClick = { viewModel.toggleFavorite(station) },
nowTrack = nowPlaying[station.id],
isCurrent = station.id == playingStationId,
isPlaying = isPlaying,
modifier = Modifier.animateItemPlacement()
)
}

View File

@@ -11,6 +11,7 @@ import com.radiola.domain.usecase.GetStationsUseCase
import com.radiola.domain.usecase.PlayStationUseCase
import com.radiola.domain.usecase.RefreshStationsUseCase
import com.radiola.domain.usecase.ToggleFavoriteUseCase
import com.radiola.service.PlayerController
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
@@ -25,9 +26,14 @@ class StationsViewModel @Inject constructor(
private val toggleFavoriteUseCase: ToggleFavoriteUseCase,
private val favoritesRepository: FavoritesRepository,
private val stationRepository: StationRepository,
private val nowPlayingRepository: NowPlayingRepository
private val nowPlayingRepository: NowPlayingRepository,
private val playerController: PlayerController
) : ViewModel() {
// Активная (играющая) станция — для подсветки карточки в списке.
val playingStationId: StateFlow<Int?> = playerController.currentStationId
val isPlaying: StateFlow<Boolean> = playerController.isPlaying
private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()