diff --git a/app/src/main/java/com/radiola/service/PlayerController.kt b/app/src/main/java/com/radiola/service/PlayerController.kt index ba4256f..de60ed5 100644 --- a/app/src/main/java/com/radiola/service/PlayerController.kt +++ b/app/src/main/java/com/radiola/service/PlayerController.kt @@ -32,6 +32,10 @@ class PlayerController @Inject constructor( private val _currentStationPrefix = MutableStateFlow(null) val currentStationPrefix: StateFlow = _currentStationPrefix + // Id играющей станции — для подсветки активной карточки в списке. + private val _currentStationId = MutableStateFlow(null) + val currentStationId: StateFlow = _currentStationId + private val _icyTitle = MutableStateFlow(null) val icyTitle: StateFlow = _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() { diff --git a/app/src/main/java/com/radiola/ui/components/FilterChips.kt b/app/src/main/java/com/radiola/ui/components/FilterChips.kt index eb74f4f..6f63dde 100644 --- a/app/src/main/java/com/radiola/ui/components/FilterChips.kt +++ b/app/src/main/java/com/radiola/ui/components/FilterChips.kt @@ -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) ) { diff --git a/app/src/main/java/com/radiola/ui/components/StationCard.kt b/app/src/main/java/com/radiola/ui/components/StationCard.kt index 396206d..d8e3bda 100644 --- a/app/src/main/java/com/radiola/ui/components/StationCard.kt +++ b/app/src/main/java/com/radiola/ui/components/StationCard.kt @@ -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()) + ) + } + ) +} + /** Инициалы станции для плитки-плейсхолдера (1–2 символа). */ private fun stationInitials(name: String): String { val words = name.trim().split(Regex("\\s+")).filter { it.isNotBlank() } diff --git a/app/src/main/java/com/radiola/ui/player/PlayerViewModel.kt b/app/src/main/java/com/radiola/ui/player/PlayerViewModel.kt index 42d9f0f..38692a3 100644 --- a/app/src/main/java/com/radiola/ui/player/PlayerViewModel.kt +++ b/app/src/main/java/com/radiola/ui/player/PlayerViewModel.kt @@ -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() diff --git a/app/src/main/java/com/radiola/ui/stations/StationsScreen.kt b/app/src/main/java/com/radiola/ui/stations/StationsScreen.kt index 9a0e5af..9a6a7fd 100644 --- a/app/src/main/java/com/radiola/ui/stations/StationsScreen.kt +++ b/app/src/main/java/com/radiola/ui/stations/StationsScreen.kt @@ -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(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() ) } diff --git a/app/src/main/java/com/radiola/ui/stations/StationsViewModel.kt b/app/src/main/java/com/radiola/ui/stations/StationsViewModel.kt index 2a64e0f..d79b07b 100644 --- a/app/src/main/java/com/radiola/ui/stations/StationsViewModel.kt +++ b/app/src/main/java/com/radiola/ui/stations/StationsViewModel.kt @@ -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 = playerController.currentStationId + val isPlaying: StateFlow = playerController.isPlaying + private val _searchQuery = MutableStateFlow("") val searchQuery: StateFlow = _searchQuery.asStateFlow()