feat(stations): обложка текущего трека на карточке станции + подпись
Для станций без своей обложки (и для Radio Record — единый стиль) карточка показывает обложку играющего трека с тёмным градиентом и подписью трек/исполнитель. Источник — /now-playing (теперь с name станции), матч по имени, обновление 20с. Приоритет: трек -> логотип станции -> фирменная плитка.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user