feat(stations): обложка текущего трека на карточке станции + подпись

Для станций без своей обложки (и для Radio Record — единый стиль) карточка
показывает обложку играющего трека с тёмным градиентом и подписью трек/исполнитель.
Источник — /now-playing (теперь с name станции), матч по имени, обновление 20с.
Приоритет: трек -> логотип станции -> фирменная плитка.
This commit is contained in:
nk
2026-06-03 12:18:19 +03:00
parent 9d115b148e
commit ee689ce380
8 changed files with 129 additions and 27 deletions

View File

@@ -7,6 +7,7 @@ import kotlinx.serialization.Serializable
@Serializable
data class BackendNowPlayingDto(
@SerialName("stationId") val stationId: Int,
@SerialName("name") val name: String = "",
@SerialName("song") val song: String,
@SerialName("artist") val artist: String,
@SerialName("coverUrl") val coverUrl: String? = null

View File

@@ -16,6 +16,10 @@ class NowPlayingRepositoryImpl @Inject constructor(
private val _nowPlaying = MutableStateFlow<Map<Int, Track>>(emptyMap())
// Карта по lowercase-имени станции: заполняется при REST-поллинге,
// используется для матчинга карточек (id локальных станций может отличаться).
private val _nowPlayingByName = MutableStateFlow<Map<String, Track>>(emptyMap())
init {
socketClient.connect()
}
@@ -34,6 +38,8 @@ class NowPlayingRepositoryImpl @Inject constructor(
restMap + socketMap
}
override fun getAllNowPlayingByName(): Flow<Map<String, Track>> = _nowPlayingByName
override suspend fun refreshNowPlaying(): Result<Unit> {
return try {
// Берём now-playing с нашего бэкенда: там корректный маппинг
@@ -45,7 +51,18 @@ class NowPlayingRepositoryImpl @Inject constructor(
artist = dto.artist,
song = dto.song,
coverUrl = dto.coverUrl,
stationName = ""
stationName = dto.name
)
}
// Параллельный индекс по имени — для матчинга карточек станций.
_nowPlayingByName.value = list
.filter { it.name.isNotBlank() }
.associate { dto ->
dto.name.trim().lowercase() to Track(
artist = dto.artist,
song = dto.song,
coverUrl = dto.coverUrl,
stationName = dto.name
)
}
Result.success(Unit)

View File

@@ -6,5 +6,7 @@ import kotlinx.coroutines.flow.Flow
interface NowPlayingRepository {
fun getNowPlaying(stationId: Int): Flow<Track?>
fun getAllNowPlaying(): Flow<Map<Int, Track>>
// Карта по lowercase-имени станции — для матчинга с карточками (id может не совпадать).
fun getAllNowPlayingByName(): Flow<Map<String, Track>>
suspend fun refreshNowPlaying(): Result<Unit>
}

View File

@@ -25,8 +25,8 @@ import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.composables.icons.lucide.Heart
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Radio
import com.radiola.domain.model.Station
import com.radiola.domain.model.Track
import com.radiola.ui.theme.Motion
import com.radiola.ui.theme.RadiolaTheme
import com.radiola.ui.theme.pressScale
@@ -37,7 +37,8 @@ fun StationCard(
isFavorite: Boolean,
onClick: () -> Unit,
onFavoriteClick: () -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
nowTrack: Track? = null
) {
val colors = RadiolaTheme.colors
val haptics = LocalHapticFeedback.current
@@ -60,16 +61,61 @@ fun StationCard(
.clip(RoundedCornerShape(16.dp))
.background(colors.surface2)
) {
if (!station.coverUrl.isNullOrBlank()) {
when {
// Приоритет 1: обложка текущего трека с градиентом и подписью.
!nowTrack?.coverUrl.isNullOrBlank() -> {
AsyncImage(
model = crossfadeModel(nowTrack!!.coverUrl),
contentDescription = nowTrack.song,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
// Тёмный скрим снизу для читаемости текста.
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
0f to Color.Transparent,
0.5f to Color.Transparent,
1f to Color.Black.copy(alpha = 0.8f)
)
)
)
// Название трека и исполнитель в нижнем-левом углу.
Column(
modifier = Modifier
.align(Alignment.BottomStart)
.padding(10.dp)
) {
Text(
text = nowTrack.song,
color = Color.White,
fontWeight = FontWeight.Bold,
style = androidx.compose.material3.MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis
)
Text(
text = nowTrack.artist,
color = Color.White.copy(alpha = 0.8f),
style = androidx.compose.material3.MaterialTheme.typography.labelMedium,
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis
)
}
}
// Приоритет 2: логотип самой станции.
!station.coverUrl.isNullOrBlank() -> {
AsyncImage(
model = crossfadeModel(station.coverUrl),
contentDescription = station.name,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
// Своя фирменная плитка станции (цвет из названия + инициалы),
// а не общий значок и не чужая обложка.
}
// Приоритет 3: фирменная плитка (цвет из названия + инициалы).
else -> {
Box(
modifier = Modifier
.fillMaxSize()
@@ -78,19 +124,21 @@ fun StationCard(
) {
Text(
text = stationInitials(station.name),
color = androidx.compose.ui.graphics.Color.White,
color = Color.White,
fontWeight = FontWeight.Black,
style = androidx.compose.material3.MaterialTheme.typography.headlineMedium
)
}
}
}
// Кнопка сердечка — поверх всего, top-end.
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(10.dp)
.size(32.dp)
.clip(RoundedCornerShape(16.dp))
.background(androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.4f))
.background(Color.Black.copy(alpha = 0.4f))
.clickable {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onFavoriteClick()

View File

@@ -34,6 +34,7 @@ fun FavoritesScreen(
) {
val favorites by viewModel.favorites.collectAsState()
val favoriteIds by viewModel.favoriteIds.collectAsState()
val nowPlaying by viewModel.nowPlaying.collectAsState()
val colors = RadiolaTheme.colors
Column(
@@ -93,6 +94,7 @@ fun FavoritesScreen(
isFavorite = favoriteIds.contains(station.id),
onClick = { onStationClick(station) },
onFavoriteClick = { viewModel.toggleFavorite(station) },
nowTrack = nowPlaying[station.name.trim().lowercase()],
modifier = Modifier.animateItemPlacement()
)
}

View File

@@ -3,11 +3,14 @@ package com.radiola.ui.favorites
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.radiola.domain.model.Station
import com.radiola.domain.model.Track
import com.radiola.domain.repository.FavoritesRepository
import com.radiola.domain.repository.NowPlayingRepository
import com.radiola.domain.usecase.ToggleFavoriteUseCase
import com.radiola.domain.usecase.auth.PushFavoriteUseCase
import com.radiola.domain.usecase.auth.SyncFavoritesUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
@@ -20,7 +23,8 @@ class FavoritesViewModel @Inject constructor(
private val favoritesRepository: FavoritesRepository,
private val toggleFavoriteUseCase: ToggleFavoriteUseCase,
private val pushFavoriteUseCase: PushFavoriteUseCase,
private val syncFavoritesUseCase: SyncFavoritesUseCase
private val syncFavoritesUseCase: SyncFavoritesUseCase,
private val nowPlayingRepository: NowPlayingRepository
) : ViewModel() {
val favorites: StateFlow<List<Station>> = favoritesRepository.getFavorites()
@@ -30,10 +34,21 @@ class FavoritesViewModel @Inject constructor(
.map { list -> list.map { it.id }.toSet() }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptySet())
// Текущие треки по lowercase-имени станции — для обложек на карточках.
val nowPlaying: StateFlow<Map<String, Track>> = nowPlayingRepository.getAllNowPlayingByName()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap())
init {
viewModelScope.launch {
syncFavoritesUseCase()
}
// Периодическое обновление now-playing каждые 20 секунд.
viewModelScope.launch {
while (true) {
nowPlayingRepository.refreshNowPlaying()
delay(20_000)
}
}
}
fun toggleFavorite(station: Station) {

View File

@@ -34,6 +34,7 @@ fun StationsScreen(
val isLoading by viewModel.isLoading.collectAsState()
val error by viewModel.error.collectAsState()
val favoriteIds by viewModel.favoriteIds.collectAsState()
val nowPlaying by viewModel.nowPlaying.collectAsState()
val colors = RadiolaTheme.colors
Column(modifier = modifier.fillMaxSize()) {
@@ -117,6 +118,7 @@ fun StationsScreen(
isFavorite = favoriteIds.contains(station.id),
onClick = { onStationClick(station) },
onFavoriteClick = { viewModel.toggleFavorite(station) },
nowTrack = nowPlaying[station.name.trim().lowercase()],
modifier = Modifier.animateItemPlacement()
)
}

View File

@@ -3,13 +3,16 @@ package com.radiola.ui.stations
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.radiola.domain.model.Station
import com.radiola.domain.model.Track
import com.radiola.domain.repository.FavoritesRepository
import com.radiola.domain.repository.NowPlayingRepository
import com.radiola.domain.repository.StationRepository
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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -21,7 +24,8 @@ class StationsViewModel @Inject constructor(
private val playStationUseCase: PlayStationUseCase,
private val toggleFavoriteUseCase: ToggleFavoriteUseCase,
private val favoritesRepository: FavoritesRepository,
private val stationRepository: StationRepository
private val stationRepository: StationRepository,
private val nowPlayingRepository: NowPlayingRepository
) : ViewModel() {
private val _searchQuery = MutableStateFlow("")
@@ -61,6 +65,10 @@ class StationsViewModel @Inject constructor(
.map { list -> list.map { it.id }.toSet() }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptySet())
// Текущие треки по lowercase-имени станции — для обложек на карточках.
val nowPlaying: StateFlow<Map<String, Track>> = nowPlayingRepository.getAllNowPlayingByName()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap())
init {
viewModelScope.launch {
_isLoading.value = true
@@ -68,6 +76,13 @@ class StationsViewModel @Inject constructor(
.onFailure { _error.value = it.localizedMessage ?: "Ошибка загрузки" }
_isLoading.value = false
}
// Периодическое обновление now-playing каждые 20 секунд.
viewModelScope.launch {
while (true) {
nowPlayingRepository.refreshNowPlaying()
delay(20_000)
}
}
}
fun onSearchQueryChange(query: String) {