Compare commits

...

4 Commits

Author SHA1 Message Date
nk
a3f3494da2 polish(ui): плавные обложки, бегущая строка длинных названий, тактильный отклик
- Coil crossfade для всех обложек (Images.crossfadeModel) — без «моргания» при загрузке
- basicMarquee для длинных названий трека (плеер и мини-плеер) вместо обрезки
- haptic feedback на play/pause и добавление в избранное (плеер, мини-плеер, карточка)
2026-06-02 22:33:33 +03:00
nk
8a951dd4c5 fix(player): отображение трека и обложки — объединение REST и socket now-playing
REST-поллинг (refreshNowPlaying -> api.getNowPlaying, 200 OK) писал данные в
_nowPlaying, который нигде не читался; getNowPlaying() брал только сокет (пустой).
Теперь getNowPlaying/getAllNowPlaying объединяют оба источника (socket ?: REST),
поэтому название трека, обложка и deep-link сервисов работают.
2026-06-02 22:23:37 +03:00
nk
58f735823e fix(ui): отступы под системную навигацию + подписи сервисов + краш навбара
- навбар и мини-плеер: navigationBarsPadding — не налезают на системные кнопки
- плеер: navigationBarsPadding снизу, ряд сервисов не уходит под системную панель
- подписи сервисов без обрезки слов (Яндекс / ВК Музыка / YT Music и т.д.)
- фикс NPE при холодном старте: навбар обращается к NavDestinations напрямую,
  не к companion-списку (порядок инициализации Kotlin)
2026-06-02 22:13:10 +03:00
nk
9e9f4c8009 fix(ui): единый скролл на экране станций + всегда видимый навбар
- StationsScreen: закреплённые заголовок/поиск/жанры, одна прокручиваемая
  сетка станций; поиск и фильтры больше не исчезают при пустом результате
  (+ кнопка «Сбросить фильтры»)
- таб-бар показывается без обязательного входа (скрыт только на экране входа)
- старт сразу со «Станций» — авторизация необязательна, вход из Настроек
2026-06-02 21:58:11 +03:00
9 changed files with 182 additions and 93 deletions

View File

@@ -9,9 +9,11 @@ import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.lifecycle.lifecycleScope
import com.radiola.data.local.TokenDataStore
@@ -65,23 +67,30 @@ class MainActivity : ComponentActivity() {
val isRecording by playerViewModel.isRecording.collectAsState()
val isLoggedIn by tokenDataStore.isLoggedIn.collectAsState(initial = false)
val startDestination = remember(isLoggedIn) {
if (isLoggedIn) NavDestinations.Stations.route else NavDestinations.Auth.route
}
// Авторизация необязательна — всегда стартуем со станций.
// Вход доступен из Настроек.
val startDestination = NavDestinations.Stations.route
val currentRoute = navController
.currentBackStackEntryAsState().value?.destination?.route
val showChrome = currentRoute != NavDestinations.Auth.route
Scaffold(
bottomBar = {
Column {
if (currentStation != null) {
MiniPlayer(
stationName = currentStation!!.name,
track = currentTrack,
isPlaying = isPlaying,
onClick = { showPlayer = true },
onPlayPause = { playerViewModel.togglePlayPause() }
)
}
if (isLoggedIn) {
if (showChrome) {
Column(Modifier.navigationBarsPadding()) {
if (currentStation != null) {
MiniPlayer(
stationName = currentStation!!.name,
track = currentTrack,
isPlaying = isPlaying,
onClick = { showPlayer = true },
onPlayPause = { playerViewModel.togglePlayPause() }
)
Spacer(Modifier.height(8.dp))
}
// Навигация доступна и без входа — приложением можно
// пользоваться анонимно.
BottomNavBar(navController)
}
}

View File

@@ -7,7 +7,7 @@ import com.radiola.domain.model.Track
import com.radiola.domain.repository.NowPlayingRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.combine
import javax.inject.Inject
class NowPlayingRepositoryImpl @Inject constructor(
@@ -21,11 +21,19 @@ class NowPlayingRepositoryImpl @Inject constructor(
socketClient.connect()
}
// Объединяем два источника: сокет (реалтайм, приоритет) и REST-поллинг
// (refreshNowPlaying). Раньше REST-данные писались в _nowPlaying, но никем
// не читались — из-за этого трек и обложка не отображались.
override fun getNowPlaying(stationId: Int): Flow<Track?> {
return socketClient.nowPlaying.map { it[stationId] }
return combine(socketClient.nowPlaying, _nowPlaying) { socketMap, restMap ->
socketMap[stationId] ?: restMap[stationId]
}
}
override fun getAllNowPlaying(): Flow<Map<Int, Track>> = socketClient.nowPlaying
override fun getAllNowPlaying(): Flow<Map<Int, Track>> =
combine(socketClient.nowPlaying, _nowPlaying) { socketMap, restMap ->
restMap + socketMap
}
override suspend fun refreshNowPlaying(): Result<Unit> {
return try {

View File

@@ -0,0 +1,16 @@
package com.radiola.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import coil.request.ImageRequest
/**
* Модель изображения с плавным проявлением (crossfade).
* Используется во всех обложках, чтобы загрузка не «моргала».
*/
@Composable
fun crossfadeModel(url: String?): ImageRequest =
ImageRequest.Builder(LocalContext.current)
.data(url)
.crossfade(220)
.build()

View File

@@ -2,6 +2,10 @@ package com.radiola.ui.components
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.basicMarquee
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -29,6 +33,7 @@ import com.radiola.ui.theme.Motion
import com.radiola.ui.theme.RadiolaTheme
import com.radiola.ui.theme.pressScale
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MiniPlayer(
stationName: String,
@@ -39,6 +44,7 @@ fun MiniPlayer(
modifier: Modifier = Modifier
) {
val colors = RadiolaTheme.colors
val haptics = LocalHapticFeedback.current
Row(
modifier = modifier
.fillMaxWidth()
@@ -59,7 +65,7 @@ fun MiniPlayer(
) {
if (track?.coverUrl != null) {
AsyncImage(
model = track.coverUrl,
model = crossfadeModel(track.coverUrl),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
@@ -83,7 +89,7 @@ fun MiniPlayer(
style = androidx.compose.material3.MaterialTheme.typography.titleMedium,
color = colors.textPrimary,
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis
modifier = Modifier.basicMarquee()
)
}
Spacer(Modifier.width(8.dp))
@@ -95,9 +101,11 @@ fun MiniPlayer(
.background(colors.accent)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onPlayPause
),
indication = null
) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onPlayPause()
},
contentAlignment = Alignment.Center
) {
Crossfade(targetState = isPlaying, animationSpec = tween(Motion.Fast), label = "miniPlay") { playing ->

View File

@@ -15,7 +15,9 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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
@@ -36,6 +38,7 @@ fun StationCard(
modifier: Modifier = Modifier
) {
val colors = RadiolaTheme.colors
val haptics = LocalHapticFeedback.current
val interaction = remember { MutableInteractionSource() }
val heartTint by animateColorAsState(
targetValue = if (isFavorite) colors.accent else colors.textPrimary,
@@ -57,7 +60,7 @@ fun StationCard(
) {
if (!station.coverUrl.isNullOrBlank()) {
AsyncImage(
model = station.coverUrl,
model = crossfadeModel(station.coverUrl),
contentDescription = station.name,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
@@ -77,7 +80,10 @@ fun StationCard(
.size(32.dp)
.clip(RoundedCornerShape(16.dp))
.background(androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.4f))
.clickable(onClick = onFavoriteClick),
.clickable {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onFavoriteClick()
},
contentAlignment = Alignment.Center
) {
Icon(

View File

@@ -51,7 +51,7 @@ fun TrackListItem(
) {
if (!track.coverUrl.isNullOrBlank()) {
AsyncImage(
model = track.coverUrl,
model = crossfadeModel(track.coverUrl),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop

View File

@@ -37,7 +37,15 @@ import com.radiola.ui.theme.RadiolaTheme
fun BottomNavBar(navController: NavController) {
val colors = RadiolaTheme.colors
val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route
val items = NavDestinations.items.filter { it.showInBottomBar }
// Обращаемся к объектам напрямую: companion-список NavDestinations.items
// при холодном старте может содержать null (порядок инициализации Kotlin).
val items = listOf(
NavDestinations.Stations,
NavDestinations.Favorites,
NavDestinations.History,
NavDestinations.Recordings,
NavDestinations.Settings
)
Row(
modifier = Modifier

View File

@@ -1,6 +1,8 @@
package com.radiola.ui.player
import android.util.Log
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.basicMarquee
import androidx.compose.animation.Crossfade
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
@@ -8,6 +10,7 @@ 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.layout.navigationBarsPadding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
@@ -19,7 +22,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
@@ -46,6 +51,7 @@ import com.radiola.ui.theme.Motion
import com.radiola.ui.theme.RadiolaTheme
import com.radiola.ui.theme.pressScale
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun PlayerBottomSheet(
station: Station?,
@@ -64,11 +70,13 @@ fun PlayerBottomSheet(
val context = LocalContext.current
val enabledServices by viewModel.enabledServices.collectAsState()
val colors = RadiolaTheme.colors
val haptics = LocalHapticFeedback.current
Column(
modifier = modifier
.fillMaxWidth()
.background(colors.bgBase)
.navigationBarsPadding()
.padding(horizontal = 24.dp, vertical = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
@@ -93,7 +101,7 @@ fun PlayerBottomSheet(
val coverModel = track?.coverUrl ?: station?.coverUrl
if (!coverModel.isNullOrBlank()) {
AsyncImage(
model = coverModel,
model = com.radiola.ui.components.crossfadeModel(coverModel),
contentDescription = station?.name,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
@@ -121,8 +129,8 @@ fun PlayerBottomSheet(
style = MaterialTheme.typography.headlineLarge,
color = colors.textPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
modifier = Modifier.basicMarquee()
)
Spacer(Modifier.height(4.dp))
Text(
@@ -159,7 +167,13 @@ fun PlayerBottomSheet(
label = "heartTint"
)
PlayerIconBtn(size = 44.dp) {
IconButton(onClick = onToggleFavorite, modifier = Modifier.size(44.dp)) {
IconButton(
onClick = {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onToggleFavorite()
},
modifier = Modifier.size(44.dp)
) {
Icon(Lucide.Heart, "Избранное", tint = heartTint, modifier = Modifier.size(22.dp))
}
}
@@ -179,7 +193,10 @@ fun PlayerBottomSheet(
.clip(CircleShape)
.background(colors.accent)
.pressScale(interactionSource = playInteraction)
.clickable(interactionSource = playInteraction, indication = null, onClick = onPlayPause),
.clickable(interactionSource = playInteraction, indication = null) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onPlayPause()
},
contentAlignment = Alignment.Center
) {
Crossfade(
@@ -262,7 +279,20 @@ private fun PlayerIconBtn(
}
}
/** Монохромная кнопка сервиса для поиска трека (без официальных логотипов). */
/** Короткая подпись сервиса под кнопкой (без обрезки слов). */
private fun serviceShortName(service: DeeplinkService): String = when (service.serviceId) {
"yandex" -> "Яндекс"
"vk" -> "ВК Музыка"
"boom" -> "BOOM"
"spotify" -> "Spotify"
"apple" -> "Apple Music"
"youtube" -> "YT Music"
"tidal" -> "Tidal"
"deezer" -> "Deezer"
else -> service.displayName
}
/** Монохромная кнопка сервиса для поиска трека. */
@Composable
private fun ServiceDeeplinkBtn(
service: DeeplinkService,
@@ -302,7 +332,7 @@ private fun ServiceDeeplinkBtn(
}
}
Text(
text = service.displayName.take(8),
text = serviceShortName(service),
style = MaterialTheme.typography.labelSmall,
color = colors.textSecondary,
maxLines = 1,

View File

@@ -1,12 +1,8 @@
package com.radiola.ui.stations
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.slideInVertically
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.*
@@ -18,6 +14,8 @@ import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Radio
import com.radiola.domain.model.Station
import com.radiola.ui.components.*
import com.radiola.ui.theme.RadiolaTheme
@@ -38,11 +36,7 @@ fun StationsScreen(
val favoriteIds by viewModel.favoriteIds.collectAsState()
val colors = RadiolaTheme.colors
Column(
modifier = modifier
.fillMaxSize()
.padding(horizontal = 20.dp)
) {
Column(modifier = modifier.fillMaxSize()) {
// Двухцветный заголовок экрана
Text(
text = buildAnnotatedString {
@@ -50,72 +44,82 @@ fun StationsScreen(
withStyle(SpanStyle(color = colors.accent)) { append("радио") }
},
style = MaterialTheme.typography.headlineLarge,
modifier = Modifier.padding(top = 20.dp, bottom = 16.dp)
modifier = Modifier.padding(start = 20.dp, end = 20.dp, top = 20.dp, bottom = 16.dp)
)
when {
isLoading && stations.isEmpty() -> Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(color = colors.accent)
}
// Поиск — всегда виден (в т.ч. когда результатов нет)
SearchBar(
query = searchQuery,
onQueryChange = viewModel::onSearchQueryChange,
modifier = Modifier.padding(horizontal = 20.dp)
)
Spacer(Modifier.height(12.dp))
stations.isEmpty() -> {
AnimatedVisibility(
visible = true,
enter = fadeIn() + slideInVertically()
) {
// Жанры — всегда видны
if (tags.isNotEmpty()) {
FilterChips(
tags = tags,
selectedTag = selectedTag,
onTagSelected = viewModel::onTagSelected
)
Spacer(Modifier.height(8.dp))
}
// Область результатов — единственная прокручиваемая зона
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
when {
isLoading && stations.isEmpty() -> {
CircularProgressIndicator(
color = colors.accent,
modifier = Modifier.align(Alignment.Center)
)
}
stations.isEmpty() -> {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
verticalArrangement = Arrangement.Center
) {
EmptyState(message = error ?: "Станции не найдены")
if (selectedTag != null) {
EmptyState(
message = error
?: if (searchQuery.isNotBlank() || selectedTag != null)
"Ничего не найдено" else "Станции не найдены",
icon = Lucide.Radio,
modifier = Modifier.wrapContentSize()
)
if (searchQuery.isNotBlank() || selectedTag != null) {
Spacer(Modifier.height(12.dp))
OutlinedButton(
onClick = { viewModel.onTagSelected(null) },
colors = ButtonDefaults.outlinedButtonColors(
contentColor = colors.accent
),
onClick = {
viewModel.onSearchQueryChange("")
viewModel.onTagSelected(null)
},
colors = ButtonDefaults.outlinedButtonColors(contentColor = colors.accent),
border = androidx.compose.foundation.BorderStroke(1.dp, colors.accent)
) {
Text("Показать все")
Text("Сбросить фильтры")
}
}
}
}
}
else -> LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
item(span = { GridItemSpan(maxLineSpan) }) {
SearchBar(
query = searchQuery,
onQueryChange = viewModel::onSearchQueryChange,
)
}
item(span = { GridItemSpan(maxLineSpan) }) {
FilterChips(
tags = tags,
selectedTag = selectedTag,
onTagSelected = viewModel::onTagSelected,
modifier = Modifier.padding(vertical = 8.dp)
)
}
items(stations, key = { it.id }) { station ->
StationCard(
station = station,
isFavorite = favoriteIds.contains(station.id),
onClick = { onStationClick(station) },
onFavoriteClick = { viewModel.toggleFavorite(station) },
modifier = Modifier.animateItemPlacement()
)
else -> LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 4.dp, bottom = 20.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
items(stations, key = { it.id }) { station ->
StationCard(
station = station,
isFavorite = favoriteIds.contains(station.id),
onClick = { onStationClick(station) },
onFavoriteClick = { viewModel.toggleFavorite(station) },
modifier = Modifier.animateItemPlacement()
)
}
}
}
}