diff --git a/app/src/main/java/com/radiola/data/remote/dto/BackendNowPlayingDto.kt b/app/src/main/java/com/radiola/data/remote/dto/BackendNowPlayingDto.kt index 7d64178..04f80ee 100644 --- a/app/src/main/java/com/radiola/data/remote/dto/BackendNowPlayingDto.kt +++ b/app/src/main/java/com/radiola/data/remote/dto/BackendNowPlayingDto.kt @@ -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 diff --git a/app/src/main/java/com/radiola/data/repository/NowPlayingRepositoryImpl.kt b/app/src/main/java/com/radiola/data/repository/NowPlayingRepositoryImpl.kt index a380a76..0256b21 100644 --- a/app/src/main/java/com/radiola/data/repository/NowPlayingRepositoryImpl.kt +++ b/app/src/main/java/com/radiola/data/repository/NowPlayingRepositoryImpl.kt @@ -16,6 +16,10 @@ class NowPlayingRepositoryImpl @Inject constructor( private val _nowPlaying = MutableStateFlow>(emptyMap()) + // Карта по lowercase-имени станции: заполняется при REST-поллинге, + // используется для матчинга карточек (id локальных станций может отличаться). + private val _nowPlayingByName = MutableStateFlow>(emptyMap()) + init { socketClient.connect() } @@ -34,6 +38,8 @@ class NowPlayingRepositoryImpl @Inject constructor( restMap + socketMap } + override fun getAllNowPlayingByName(): Flow> = _nowPlayingByName + override suspend fun refreshNowPlaying(): Result { return try { // Берём now-playing с нашего бэкенда: там корректный маппинг @@ -45,9 +51,20 @@ 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) } catch (e: Exception) { Result.failure(e) diff --git a/app/src/main/java/com/radiola/domain/repository/NowPlayingRepository.kt b/app/src/main/java/com/radiola/domain/repository/NowPlayingRepository.kt index 764b67f..e3467c8 100644 --- a/app/src/main/java/com/radiola/domain/repository/NowPlayingRepository.kt +++ b/app/src/main/java/com/radiola/domain/repository/NowPlayingRepository.kt @@ -6,5 +6,7 @@ import kotlinx.coroutines.flow.Flow interface NowPlayingRepository { fun getNowPlaying(stationId: Int): Flow fun getAllNowPlaying(): Flow> + // Карта по lowercase-имени станции — для матчинга с карточками (id может не совпадать). + fun getAllNowPlayingByName(): Flow> suspend fun refreshNowPlaying(): Result } diff --git a/app/src/main/java/com/radiola/ui/components/StationCard.kt b/app/src/main/java/com/radiola/ui/components/StationCard.kt index 7103647..5778f96 100644 --- a/app/src/main/java/com/radiola/ui/components/StationCard.kt +++ b/app/src/main/java/com/radiola/ui/components/StationCard.kt @@ -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,37 +61,84 @@ fun StationCard( .clip(RoundedCornerShape(16.dp)) .background(colors.surface2) ) { - if (!station.coverUrl.isNullOrBlank()) { - AsyncImage( - model = crossfadeModel(station.coverUrl), - contentDescription = station.name, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop - ) - } else { - // Своя фирменная плитка станции (цвет из названия + инициалы), - // а не общий значок и не чужая обложка. - Box( - modifier = Modifier - .fillMaxSize() - .background(stationTileBrush(station.name)), - contentAlignment = Alignment.Center - ) { - Text( - text = stationInitials(station.name), - color = androidx.compose.ui.graphics.Color.White, - fontWeight = FontWeight.Black, - style = androidx.compose.material3.MaterialTheme.typography.headlineMedium + 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 ) } + // Приоритет 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( 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() diff --git a/app/src/main/java/com/radiola/ui/favorites/FavoritesScreen.kt b/app/src/main/java/com/radiola/ui/favorites/FavoritesScreen.kt index 6ecd9b9..d87ae3b 100644 --- a/app/src/main/java/com/radiola/ui/favorites/FavoritesScreen.kt +++ b/app/src/main/java/com/radiola/ui/favorites/FavoritesScreen.kt @@ -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() ) } diff --git a/app/src/main/java/com/radiola/ui/favorites/FavoritesViewModel.kt b/app/src/main/java/com/radiola/ui/favorites/FavoritesViewModel.kt index 1b7f2fd..ec417b1 100644 --- a/app/src/main/java/com/radiola/ui/favorites/FavoritesViewModel.kt +++ b/app/src/main/java/com/radiola/ui/favorites/FavoritesViewModel.kt @@ -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> = 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> = 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) { diff --git a/app/src/main/java/com/radiola/ui/stations/StationsScreen.kt b/app/src/main/java/com/radiola/ui/stations/StationsScreen.kt index 0810670..53f0ed4 100644 --- a/app/src/main/java/com/radiola/ui/stations/StationsScreen.kt +++ b/app/src/main/java/com/radiola/ui/stations/StationsScreen.kt @@ -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() ) } diff --git a/app/src/main/java/com/radiola/ui/stations/StationsViewModel.kt b/app/src/main/java/com/radiola/ui/stations/StationsViewModel.kt index c401c96..12afac2 100644 --- a/app/src/main/java/com/radiola/ui/stations/StationsViewModel.kt +++ b/app/src/main/java/com/radiola/ui/stations/StationsViewModel.kt @@ -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> = 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) {