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

View File

@@ -9,9 +9,11 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
@@ -31,8 +33,15 @@ fun FilterChips(
onTagSelected: (String?) -> Unit, onTagSelected: (String?) -> Unit,
modifier: Modifier = Modifier 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( LazyRow(
modifier = modifier, modifier = modifier,
state = listState,
horizontalArrangement = Arrangement.spacedBy(9.dp), horizontalArrangement = Arrangement.spacedBy(9.dp),
contentPadding = PaddingValues(horizontal = 16.dp) contentPadding = PaddingValues(horizontal = 16.dp)
) { ) {

View File

@@ -1,6 +1,12 @@
package com.radiola.ui.components package com.radiola.ui.components
import androidx.compose.animation.animateColorAsState 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.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -14,7 +20,13 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.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.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
@@ -38,7 +50,9 @@ fun StationCard(
onClick: () -> Unit, onClick: () -> Unit,
onFavoriteClick: () -> Unit, onFavoriteClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
nowTrack: Track? = null nowTrack: Track? = null,
isCurrent: Boolean = false,
isPlaying: Boolean = false
) { ) {
val colors = RadiolaTheme.colors val colors = RadiolaTheme.colors
val haptics = LocalHapticFeedback.current val haptics = LocalHapticFeedback.current
@@ -57,7 +71,20 @@ fun StationCard(
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.aspectRatio(1f) .aspectRatio(1f),
contentAlignment = Alignment.Center
) {
// Свечение активной станции — позади обложки, мягко вылезает из-под краёв.
if (isCurrent) {
PlayingGlow(
modifier = Modifier.matchParentSize(),
color = colors.accent,
playing = isPlaying
)
}
Box(
modifier = Modifier
.matchParentSize()
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(16.dp))
.background(colors.surface2) .background(colors.surface2)
) { ) {
@@ -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. // Кнопка сердечка — поверх всего, top-end.
Box( Box(
modifier = Modifier modifier = Modifier
@@ -153,6 +199,7 @@ fun StationCard(
) )
} }
} }
}
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
Text( Text(
text = station.name, text = station.name,
@@ -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 символа). */ /** Инициалы станции для плитки-плейсхолдера (12 символа). */
private fun stationInitials(name: String): String { private fun stationInitials(name: String): String {
val words = name.trim().split(Regex("\\s+")).filter { it.isNotBlank() } val words = name.trim().split(Regex("\\s+")).filter { it.isNotBlank() }

View File

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

View File

@@ -1,6 +1,7 @@
package com.radiola.ui.stations package com.radiola.ui.stations
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
@@ -9,6 +10,9 @@ import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
@@ -35,7 +39,22 @@ fun StationsScreen(
val error by viewModel.error.collectAsState() val error by viewModel.error.collectAsState()
val favoriteIds by viewModel.favoriteIds.collectAsState() val favoriteIds by viewModel.favoriteIds.collectAsState()
val nowPlaying by viewModel.nowPlaying.collectAsState() val nowPlaying by viewModel.nowPlaying.collectAsState()
val playingStationId by viewModel.playingStationId.collectAsState()
val isPlaying by viewModel.isPlaying.collectAsState()
val colors = RadiolaTheme.colors 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()) { Column(modifier = modifier.fillMaxSize()) {
// Двухцветный заголовок экрана // Двухцветный заголовок экрана
@@ -66,8 +85,26 @@ fun StationsScreen(
Spacer(Modifier.height(8.dp)) 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 { when {
isLoading && stations.isEmpty() -> { isLoading && stations.isEmpty() -> {
CircularProgressIndicator( CircularProgressIndicator(
@@ -119,6 +156,8 @@ fun StationsScreen(
onClick = { onStationClick(station) }, onClick = { onStationClick(station) },
onFavoriteClick = { viewModel.toggleFavorite(station) }, onFavoriteClick = { viewModel.toggleFavorite(station) },
nowTrack = nowPlaying[station.id], nowTrack = nowPlaying[station.id],
isCurrent = station.id == playingStationId,
isPlaying = isPlaying,
modifier = Modifier.animateItemPlacement() 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.PlayStationUseCase
import com.radiola.domain.usecase.RefreshStationsUseCase import com.radiola.domain.usecase.RefreshStationsUseCase
import com.radiola.domain.usecase.ToggleFavoriteUseCase import com.radiola.domain.usecase.ToggleFavoriteUseCase
import com.radiola.service.PlayerController
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
@@ -25,9 +26,14 @@ class StationsViewModel @Inject constructor(
private val toggleFavoriteUseCase: ToggleFavoriteUseCase, private val toggleFavoriteUseCase: ToggleFavoriteUseCase,
private val favoritesRepository: FavoritesRepository, private val favoritesRepository: FavoritesRepository,
private val stationRepository: StationRepository, private val stationRepository: StationRepository,
private val nowPlayingRepository: NowPlayingRepository private val nowPlayingRepository: NowPlayingRepository,
private val playerController: PlayerController
) : ViewModel() { ) : ViewModel() {
// Активная (играющая) станция — для подсветки карточки в списке.
val playingStationId: StateFlow<Int?> = playerController.currentStationId
val isPlaying: StateFlow<Boolean> = playerController.isPlaying
private val _searchQuery = MutableStateFlow("") private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow() val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()