fix(now-playing): матч текущего трека по id станции, а не по имени

Станции с одинаковым именем в разных сетях (напр. «Deep» у Record и DFM)
показывали один и тот же трек — матч был по lowercase-имени. Каталожный id
(== station.id) уникален и совпадает со stationId в /now-playing, поэтому
матчим по id. Убран весь by-name путь (репозиторий, плеер, карточки).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nk
2026-06-03 15:54:24 +03:00
parent eeceb754ea
commit 1ef60b6053
8 changed files with 15 additions and 44 deletions

View File

@@ -16,27 +16,16 @@ 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()
} }
// Сокет (реалтайм, приоритет) + REST-поллинг с нашего бэкенда. // Сокет (реалтайм, приоритет) + REST-поллинг с нашего бэкенда.
// Оба источника ключуются по числовому id станции (как в каталоге), // Оба источника ключуются по числовому id станции каталога (== station.id),
// поэтому корректно сопоставляются с station.id плеера. // поэтому матчатся однозначно — без коллизий по одинаковым названиям станций.
override fun getNowPlaying(stationId: Int, stationName: String): Flow<Track?> { override fun getNowPlaying(stationId: Int): Flow<Track?> {
val nameKey = stationName.trim().lowercase() return combine(socketClient.nowPlaying, _nowPlaying) { socketMap, restMap ->
return combine( socketMap[stationId] ?: restMap[stationId]
socketClient.nowPlaying,
_nowPlaying,
_nowPlayingByName
) { socketMap, restMap, byName ->
// Числовой id (сокет/REST), затем фолбэк по имени — id локальных
// станций (DFM и др.) не совпадает с каталожным, имя совпадает.
socketMap[stationId] ?: restMap[stationId] ?: byName[nameKey]
} }
} }
@@ -45,13 +34,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-слотов Record -> id станций (recordSync). Сырой Record-эндпоинт
// использует id now-слотов, которые не совпадают с id каталога.
val list = radiolaApi.getNowPlaying() val list = radiolaApi.getNowPlaying()
_nowPlaying.value = list.associate { dto -> _nowPlaying.value = list.associate { dto ->
dto.stationId to Track( dto.stationId to Track(
@@ -61,17 +45,6 @@ class NowPlayingRepositoryImpl @Inject constructor(
stationName = dto.name 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)

View File

@@ -4,9 +4,7 @@ import com.radiola.domain.model.Track
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
interface NowPlayingRepository { interface NowPlayingRepository {
fun getNowPlaying(stationId: Int, stationName: String): 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

@@ -8,7 +8,7 @@ import javax.inject.Inject
class GetNowPlayingUseCase @Inject constructor( class GetNowPlayingUseCase @Inject constructor(
private val nowPlayingRepository: NowPlayingRepository private val nowPlayingRepository: NowPlayingRepository
) { ) {
operator fun invoke(stationId: Int, stationName: String): Flow<Track?> { operator fun invoke(stationId: Int): Flow<Track?> {
return nowPlayingRepository.getNowPlaying(stationId, stationName) return nowPlayingRepository.getNowPlaying(stationId)
} }
} }

View File

@@ -94,7 +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()], nowTrack = nowPlaying[station.id],
modifier = Modifier.animateItemPlacement() modifier = Modifier.animateItemPlacement()
) )
} }

View File

@@ -34,8 +34,8 @@ 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-имени станции — для обложек на карточках. // Текущие треки по id станции (каталожный == station.id) — без коллизий по имени.
val nowPlaying: StateFlow<Map<String, Track>> = nowPlayingRepository.getAllNowPlayingByName() val nowPlaying: StateFlow<Map<Int, Track>> = nowPlayingRepository.getAllNowPlaying()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap())
init { init {

View File

@@ -98,7 +98,7 @@ class PlayerViewModel @Inject constructor(
} }
// Collect now playing for this station (API has priority: covers + accurate metadata) // Collect now playing for this station (API has priority: covers + accurate metadata)
launch { launch {
getNowPlayingUseCase(station.id, station.name) getNowPlayingUseCase(station.id)
.distinctUntilChanged() .distinctUntilChanged()
.collect { track -> .collect { track ->
if (track != null) { if (track != null) {

View File

@@ -118,7 +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()], nowTrack = nowPlaying[station.id],
modifier = Modifier.animateItemPlacement() modifier = Modifier.animateItemPlacement()
) )
} }

View File

@@ -65,8 +65,8 @@ 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-имени станции — для обложек на карточках. // Текущие треки по id станции (каталожный == station.id) — без коллизий по имени.
val nowPlaying: StateFlow<Map<String, Track>> = nowPlayingRepository.getAllNowPlayingByName() val nowPlaying: StateFlow<Map<Int, Track>> = nowPlayingRepository.getAllNowPlaying()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap())
init { init {