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 @Serializable
data class BackendNowPlayingDto( data class BackendNowPlayingDto(
@SerialName("stationId") val stationId: Int, @SerialName("stationId") val stationId: Int,
@SerialName("name") val name: String = "",
@SerialName("song") val song: String, @SerialName("song") val song: String,
@SerialName("artist") val artist: String, @SerialName("artist") val artist: String,
@SerialName("coverUrl") val coverUrl: String? = null @SerialName("coverUrl") val coverUrl: String? = null

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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