feat(stations): обложка текущего трека на карточке станции + подпись
Для станций без своей обложки (и для Radio Record — единый стиль) карточка показывает обложку играющего трека с тёмным градиентом и подписью трек/исполнитель. Источник — /now-playing (теперь с name станции), матч по имени, обновление 20с. Приоритет: трек -> логотип станции -> фирменная плитка.
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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,9 +51,20 @@ 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)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,37 +61,84 @@ fun StationCard(
|
|||||||
.clip(RoundedCornerShape(16.dp))
|
.clip(RoundedCornerShape(16.dp))
|
||||||
.background(colors.surface2)
|
.background(colors.surface2)
|
||||||
) {
|
) {
|
||||||
if (!station.coverUrl.isNullOrBlank()) {
|
when {
|
||||||
AsyncImage(
|
// Приоритет 1: обложка текущего трека с градиентом и подписью.
|
||||||
model = crossfadeModel(station.coverUrl),
|
!nowTrack?.coverUrl.isNullOrBlank() -> {
|
||||||
contentDescription = station.name,
|
AsyncImage(
|
||||||
modifier = Modifier.fillMaxSize(),
|
model = crossfadeModel(nowTrack!!.coverUrl),
|
||||||
contentScale = ContentScale.Crop
|
contentDescription = nowTrack.song,
|
||||||
)
|
modifier = Modifier.fillMaxSize(),
|
||||||
} else {
|
contentScale = ContentScale.Crop
|
||||||
// Своя фирменная плитка станции (цвет из названия + инициалы),
|
)
|
||||||
// а не общий значок и не чужая обложка.
|
// Тёмный скрим снизу для читаемости текста.
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(stationTileBrush(station.name)),
|
.background(
|
||||||
contentAlignment = Alignment.Center
|
Brush.verticalGradient(
|
||||||
) {
|
0f to Color.Transparent,
|
||||||
Text(
|
0.5f to Color.Transparent,
|
||||||
text = stationInitials(station.name),
|
1f to Color.Black.copy(alpha = 0.8f)
|
||||||
color = androidx.compose.ui.graphics.Color.White,
|
)
|
||||||
fontWeight = FontWeight.Black,
|
)
|
||||||
style = androidx.compose.material3.MaterialTheme.typography.headlineMedium
|
)
|
||||||
|
// Название трека и исполнитель в нижнем-левом углу.
|
||||||
|
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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Приоритет 3: фирменная плитка (цвет из названия + инициалы).
|
||||||
|
else -> {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(stationTileBrush(station.name)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stationInitials(station.name),
|
||||||
|
color = Color.White,
|
||||||
|
fontWeight = FontWeight.Black,
|
||||||
|
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()
|
||||||
|
|||||||
@@ -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()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user