feat(charts): фильтр по жанру + жанр/стиль/лейбл/год на детальной трека

Подтягиваем обогащённые данные с бэкенда (Discogs): genre/styles/label/year
в чартах и детальной странице.

- ChartEntry/TrackStats + DTO: добавлены genre/styles/label/year
- RadiolaApi: getCharts(?genre=), новый getGenres()
- ChartsViewModel: состояние выбранного жанра + список жанров, перезагрузка
- ChartsScreen: ряд чипов-фильтров по жанру (Все + жанры),
  жанр/стили чипами и «Лейбл · Год» на детальной
- убран демо-fallback (SAMPLE_CHARTS) — бэкенд живой

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nk
2026-06-03 13:55:35 +03:00
parent b0c3dae20a
commit 99503fc77a
8 changed files with 157 additions and 136 deletions

View File

@@ -4,6 +4,7 @@ import com.radiola.data.remote.dto.AuthResponseDto
import com.radiola.data.remote.dto.BackendNowPlayingDto import com.radiola.data.remote.dto.BackendNowPlayingDto
import com.radiola.data.remote.dto.BackendStationDto import com.radiola.data.remote.dto.BackendStationDto
import com.radiola.data.remote.dto.ChartsResponseDto import com.radiola.data.remote.dto.ChartsResponseDto
import com.radiola.data.remote.dto.GenresResponseDto
import com.radiola.data.remote.dto.HistoryResponseDto import com.radiola.data.remote.dto.HistoryResponseDto
import com.radiola.data.remote.dto.MagicLinkRequestDto import com.radiola.data.remote.dto.MagicLinkRequestDto
import com.radiola.data.remote.dto.MagicLinkVerifyDto import com.radiola.data.remote.dto.MagicLinkVerifyDto
@@ -58,9 +59,13 @@ interface RadiolaApi {
@GET("charts/tracks") @GET("charts/tracks")
suspend fun getCharts( suspend fun getCharts(
@Query("period") period: String, @Query("period") period: String,
@Query("limit") limit: Int = 100 @Query("limit") limit: Int = 100,
@Query("genre") genre: String? = null
): ChartsResponseDto ): ChartsResponseDto
@GET("charts/genres")
suspend fun getGenres(): GenresResponseDto
@GET("charts/tracks/{trackId}") @GET("charts/tracks/{trackId}")
suspend fun getTrackStats(@Path("trackId") trackId: String): TrackStatsDto suspend fun getTrackStats(@Path("trackId") trackId: String): TrackStatsDto

View File

@@ -8,6 +8,12 @@ data class ChartsResponseDto(
val items: List<ChartEntryDto> = emptyList() val items: List<ChartEntryDto> = emptyList()
) )
/** DTO списка доступных жанров для фильтра. */
@Serializable
data class GenresResponseDto(
val genres: List<String> = emptyList()
)
/** Одна позиция в чарте. */ /** Одна позиция в чарте. */
@Serializable @Serializable
data class ChartEntryDto( data class ChartEntryDto(
@@ -16,6 +22,10 @@ data class ChartEntryDto(
val artist: String, val artist: String,
val song: String, val song: String,
val coverUrl: String? = null, val coverUrl: String? = null,
val genre: String? = null,
val styles: List<String> = emptyList(),
val label: String? = null,
val year: Int? = null,
val plays: Int = 0, val plays: Int = 0,
val stationsCount: Int = 0, val stationsCount: Int = 0,
val likes: Int = 0, val likes: Int = 0,
@@ -32,6 +42,10 @@ data class TrackStatsDto(
val song: String, val song: String,
val album: String? = null, val album: String? = null,
val coverUrl: String? = null, val coverUrl: String? = null,
val genre: String? = null,
val styles: List<String> = emptyList(),
val label: String? = null,
val year: Int? = null,
val releaseDate: String? = null, val releaseDate: String? = null,
val firstSeen: String? = null, val firstSeen: String? = null,
val totalPlays: Int = 0, val totalPlays: Int = 0,

View File

@@ -25,28 +25,29 @@ class ChartsRepositoryImpl @Inject constructor(
private val api: RadiolaApi private val api: RadiolaApi
) : ChartsRepository { ) : ChartsRepository {
// TODO: убрать превью, когда бэкенд отдаёт charts override suspend fun getCharts(period: ChartPeriod, genre: String?): List<ChartEntry> {
override suspend fun getCharts(period: ChartPeriod): List<ChartEntry> {
return try { return try {
val response = api.getCharts(period.apiValue) val response = api.getCharts(period.apiValue, genre = genre)
response.items.map { it.toDomain() } response.items.map { it.toDomain() }
} catch (e: Exception) { } catch (e: Exception) {
Log.w("ChartsRepository", "Эндпоинт charts недоступен, возвращаем превью-данные: ${e.message}") Log.w("ChartsRepository", "Ошибка загрузки чартов: ${e.message}")
SAMPLE_CHARTS emptyList()
} }
} }
// TODO: убрать превью, когда бэкенд отдаёт charts override suspend fun getGenres(): List<String> {
override suspend fun getTrackStats(trackId: String): TrackStats {
return try { return try {
val dto = api.getTrackStats(trackId) api.getGenres().genres
dto.toDomain()
} catch (e: Exception) { } catch (e: Exception) {
Log.w("ChartsRepository", "Эндпоинт getTrackStats недоступен, возвращаем превью-данные: ${e.message}") Log.w("ChartsRepository", "Ошибка загрузки жанров: ${e.message}")
sampleStats(trackId) emptyList()
} }
} }
override suspend fun getTrackStats(trackId: String): TrackStats {
return api.getTrackStats(trackId).toDomain()
}
override suspend fun setLiked(trackId: String, liked: Boolean) { override suspend fun setLiked(trackId: String, liked: Boolean) {
try { try {
if (liked) api.likeTrack(trackId) else api.unlikeTrack(trackId) if (liked) api.likeTrack(trackId) else api.unlikeTrack(trackId)
@@ -63,6 +64,10 @@ class ChartsRepositoryImpl @Inject constructor(
artist = artist, artist = artist,
song = song, song = song,
coverUrl = coverUrl, coverUrl = coverUrl,
genre = genre,
styles = styles,
label = label,
year = year,
plays = plays, plays = plays,
stationsCount = stationsCount, stationsCount = stationsCount,
likes = likes, likes = likes,
@@ -81,6 +86,10 @@ class ChartsRepositoryImpl @Inject constructor(
song = song, song = song,
album = album, album = album,
coverUrl = coverUrl, coverUrl = coverUrl,
genre = genre,
styles = styles,
label = label,
year = year,
releaseDate = releaseDate, releaseDate = releaseDate,
firstSeen = firstSeen, firstSeen = firstSeen,
totalPlays = totalPlays, totalPlays = totalPlays,
@@ -111,87 +120,4 @@ class ChartsRepositoryImpl @Inject constructor(
} }
return StatPoint(epochMs, value) return StatPoint(epochMs, value)
} }
// ---- Превью-данные (используются пока бэкенд не готов) ----
// TODO: убрать превью, когда бэкенд отдаёт charts
private fun sampleStats(trackId: String): TrackStats {
val entry = SAMPLE_CHARTS.firstOrNull { it.trackId == trackId }
val now = System.currentTimeMillis()
val dayMs = 86_400_000L
// 30 точек с плавным ростом + шум
fun timeline(base: Int): List<StatPoint> = (0 until 30).map { i ->
val noise = (-base / 20..base / 20).random()
StatPoint(
date = now - (29 - i) * dayMs,
value = (base * (0.4 + i * 0.02) + noise).toInt().coerceAtLeast(0)
)
} }
return TrackStats(
trackId = trackId,
artist = entry?.artist ?: "Неизвестный исполнитель",
song = entry?.song ?: "Неизвестный трек",
album = sampleAlbum(trackId),
coverUrl = entry?.coverUrl,
releaseDate = sampleReleaseDate(trackId),
firstSeen = "2024-01-15",
totalPlays = entry?.plays ?: 12_000,
totalLikes = entry?.likes ?: 430,
isLiked = false,
currentRank = entry?.rank,
peakRank = (entry?.rank ?: 5).coerceAtMost(3),
stations = listOf(
StationPlays(1, "Европа Плюс", 3_200),
StationPlays(2, "Авторадио", 2_800),
StationPlays(3, "Радио Energy", 1_900)
),
playsTimeline = timeline(entry?.plays ?: 12_000),
likesTimeline = timeline(entry?.likes ?: 430)
)
}
private fun sampleAlbum(trackId: String): String? = when (trackId) {
"track_01" -> "Чёрный альбом"
"track_02" -> "Танцы на стёклах"
"track_03" -> "Dangerous"
"track_04" -> "Воздух"
"track_05" -> "÷ (Divide)"
else -> null
}
private fun sampleReleaseDate(trackId: String): String? = when (trackId) {
"track_01" -> "2023-09-22"
"track_02" -> "2022-06-15"
"track_03" -> "1991-11-26"
"track_04" -> "2023-03-10"
"track_05" -> "2017-03-03"
else -> "2024-01-01"
}
companion object {
// TODO: убрать превью, когда бэкенд отдаёт charts
val SAMPLE_CHARTS: List<ChartEntry> = listOf(
ChartEntry(1, "track_01", "Монеточка", "Каждый раз", null, 48_200, 12, 1_840, 2, ChartTrend.UP),
ChartEntry(2, "track_02", "Земфира", "Хочешь?", null, 43_100, 15, 2_100, 1, ChartTrend.DOWN),
ChartEntry(3, "track_03", "Michael Jackson", "Billie Jean", null, 41_500, 18, 3_200, null, ChartTrend.NEW),
ChartEntry(4, "track_04", "Баста", "Моя игра", null, 38_900, 11, 1_420, 5, ChartTrend.UP),
ChartEntry(5, "track_05", "Ed Sheeran", "Shape of You", null, 36_400, 20, 2_870, 4, ChartTrend.DOWN),
ChartEntry(6, "track_06", "Ленинград", "Экспонат", null, 34_700, 9, 980, 6, ChartTrend.SAME),
ChartEntry(7, "track_07", "Imagine Dragons", "Believer", null, 33_100, 16, 1_650, 8, ChartTrend.UP),
ChartEntry(8, "track_08", "Нервы", "Друг", null, 31_500, 7, 760, 7, ChartTrend.DOWN),
ChartEntry(9, "track_09", "The Weeknd", "Blinding Lights", null, 29_800, 22, 2_440, null, ChartTrend.NEW),
ChartEntry(10, "track_10", "Сплин", "Романс", null, 27_300, 10, 690, 10, ChartTrend.SAME),
ChartEntry(11, "track_11", "Dua Lipa", "Levitating", null, 25_600, 14, 1_110, 12, ChartTrend.UP),
ChartEntry(12, "track_12", "Кино", "Группа крови", null, 23_900, 8, 870, 11, ChartTrend.DOWN),
ChartEntry(13, "track_13", "Bruno Mars", "Uptown Funk", null, 22_100, 19, 1_320, null, ChartTrend.NEW),
ChartEntry(14, "track_14", "Мумий Тролль", "Невеста", null, 20_400, 6, 510, 14, ChartTrend.SAME),
ChartEntry(15, "track_15", "Coldplay", "Yellow", null, 18_700, 13, 780, 15, ChartTrend.SAME)
)
}
}
/** Расширение для случайного числа в диапазоне (используется в preview). */
private fun IntRange.random(): Int =
if (first >= last) first else first + (Math.random() * (last - first + 1)).toInt()

View File

@@ -18,6 +18,10 @@ data class ChartEntry(
val artist: String, val artist: String,
val song: String, val song: String,
val coverUrl: String?, val coverUrl: String?,
val genre: String?,
val styles: List<String>,
val label: String?,
val year: Int?,
val plays: Int, val plays: Int,
val stationsCount: Int, val stationsCount: Int,
val likes: Int, val likes: Int,

View File

@@ -21,6 +21,10 @@ data class TrackStats(
val song: String, val song: String,
val album: String?, val album: String?,
val coverUrl: String?, val coverUrl: String?,
val genre: String?,
val styles: List<String>,
val label: String?,
val year: Int?,
val releaseDate: String?, val releaseDate: String?,
val firstSeen: String?, val firstSeen: String?,
val totalPlays: Int, val totalPlays: Int,

View File

@@ -5,7 +5,8 @@ import com.radiola.domain.model.ChartPeriod
import com.radiola.domain.model.TrackStats import com.radiola.domain.model.TrackStats
interface ChartsRepository { interface ChartsRepository {
suspend fun getCharts(period: ChartPeriod): List<ChartEntry> suspend fun getCharts(period: ChartPeriod, genre: String? = null): List<ChartEntry>
suspend fun getGenres(): List<String>
suspend fun getTrackStats(trackId: String): TrackStats suspend fun getTrackStats(trackId: String): TrackStats
suspend fun setLiked(trackId: String, liked: Boolean) suspend fun setLiked(trackId: String, liked: Boolean)
} }

View File

@@ -8,7 +8,9 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
@@ -61,7 +63,8 @@ fun ChartsScreen(
val colors = RadiolaTheme.colors val colors = RadiolaTheme.colors
val period by viewModel.period.collectAsState() val period by viewModel.period.collectAsState()
val charts by viewModel.charts.collectAsState() val charts by viewModel.charts.collectAsState()
val isPreview by viewModel.isPreview.collectAsState() val genres by viewModel.genres.collectAsState()
val selectedGenre by viewModel.selectedGenre.collectAsState()
val isLoadingCharts by viewModel.isLoadingCharts.collectAsState() val isLoadingCharts by viewModel.isLoadingCharts.collectAsState()
val selectedStats by viewModel.selectedTrackStats.collectAsState() val selectedStats by viewModel.selectedTrackStats.collectAsState()
val isLoadingStats by viewModel.isLoadingStats.collectAsState() val isLoadingStats by viewModel.isLoadingStats.collectAsState()
@@ -95,35 +98,18 @@ fun ChartsScreen(
onSelect = viewModel::selectPeriod onSelect = viewModel::selectPeriod
) )
Spacer(Modifier.height(12.dp)) // Фильтр по жанру (если бэкенд уже накопил жанры)
if (genres.isNotEmpty()) {
// Плашка «Демо-данные»
if (isPreview) {
Row(
modifier = Modifier
.padding(horizontal = 20.dp)
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(colors.surface2)
.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Icon(
imageVector = Lucide.Info,
contentDescription = null,
tint = colors.textMuted,
modifier = Modifier.size(14.dp)
)
Text(
text = "Демо-данные — бэкенд ещё не готов",
style = MaterialTheme.typography.labelSmall,
color = colors.textMuted
)
}
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
GenreSelector(
genres = genres,
selected = selectedGenre,
onSelect = viewModel::selectGenre
)
} }
Spacer(Modifier.height(12.dp))
// Список чартов // Список чартов
if (isLoadingCharts) { if (isLoadingCharts) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@@ -221,6 +207,35 @@ private fun PeriodChip(label: String, selected: Boolean, onClick: () -> Unit) {
) )
} }
// ---- Селектор жанра ----
@Composable
private fun GenreSelector(
genres: List<String>,
selected: String?,
onSelect: (String?) -> Unit
) {
LazyRow(
horizontalArrangement = Arrangement.spacedBy(9.dp),
contentPadding = PaddingValues(horizontal = 20.dp)
) {
item {
PeriodChip(
label = "Все",
selected = selected == null,
onClick = { onSelect(null) }
)
}
items(genres) { genre ->
PeriodChip(
label = genre,
selected = selected == genre,
onClick = { onSelect(genre) }
)
}
}
}
// ---- Строка чарта ---- // ---- Строка чарта ----
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@@ -436,15 +451,32 @@ private fun TrackDetailSheet(
modifier = Modifier.padding(top = 2.dp) modifier = Modifier.padding(top = 2.dp)
) )
} }
if (stats.releaseDate != null) { // Лейбл · Год (либо дата релиза как запасной вариант)
val metaLine = buildList {
stats.label?.let { add(it) }
stats.year?.let { add(it.toString()) }
}.joinToString(" · ").ifEmpty {
stats.releaseDate?.let { "Вышел: $it" } ?: ""
}
if (metaLine.isNotEmpty()) {
Text( Text(
text = "Вышел: ${stats.releaseDate}", text = metaLine,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = colors.textMuted, color = colors.textMuted,
modifier = Modifier.padding(top = 2.dp) modifier = Modifier.padding(top = 2.dp)
) )
} }
// Жанр + стили
val genreTags = buildList {
stats.genre?.let { add(it) }
addAll(stats.styles)
}.distinct()
if (genreTags.isNotEmpty()) {
Spacer(Modifier.height(10.dp))
GenreTags(tags = genreTags)
}
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
// Ряд метрик // Ряд метрик
@@ -548,6 +580,30 @@ private fun TrackDetailSheet(
} }
} }
// ---- Чипы жанра/стилей на детальной ----
@Composable
private fun GenreTags(tags: List<String>) {
val colors = RadiolaTheme.colors
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
tags.forEach { tag ->
Text(
text = tag,
style = MaterialTheme.typography.labelMedium,
color = colors.accent,
fontWeight = FontWeight.Medium,
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(colors.accent.copy(alpha = 0.12f))
.padding(horizontal = 10.dp, vertical = 5.dp)
)
}
}
}
// ---- Метрики трека ---- // ---- Метрики трека ----
@Composable @Composable

View File

@@ -3,7 +3,6 @@ package com.radiola.ui.charts
import android.util.Log import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.radiola.data.repository.ChartsRepositoryImpl
import com.radiola.domain.model.ChartEntry import com.radiola.domain.model.ChartEntry
import com.radiola.domain.model.ChartPeriod import com.radiola.domain.model.ChartPeriod
import com.radiola.domain.model.TrackStats import com.radiola.domain.model.TrackStats
@@ -26,9 +25,13 @@ class ChartsViewModel @Inject constructor(
private val _charts = MutableStateFlow<List<ChartEntry>>(emptyList()) private val _charts = MutableStateFlow<List<ChartEntry>>(emptyList())
val charts: StateFlow<List<ChartEntry>> = _charts.asStateFlow() val charts: StateFlow<List<ChartEntry>> = _charts.asStateFlow()
/** true — данные из превью (бэкенд ещё не готов). */ /** Доступные жанры для фильтра (с бэкенда). */
private val _isPreview = MutableStateFlow(false) private val _genres = MutableStateFlow<List<String>>(emptyList())
val isPreview: StateFlow<Boolean> = _isPreview.asStateFlow() val genres: StateFlow<List<String>> = _genres.asStateFlow()
/** Выбранный жанр (null — «Все»). */
private val _selectedGenre = MutableStateFlow<String?>(null)
val selectedGenre: StateFlow<String?> = _selectedGenre.asStateFlow()
private val _isLoadingCharts = MutableStateFlow(false) private val _isLoadingCharts = MutableStateFlow(false)
val isLoadingCharts: StateFlow<Boolean> = _isLoadingCharts.asStateFlow() val isLoadingCharts: StateFlow<Boolean> = _isLoadingCharts.asStateFlow()
@@ -41,6 +44,7 @@ class ChartsViewModel @Inject constructor(
init { init {
loadCharts() loadCharts()
loadGenres()
} }
fun selectPeriod(newPeriod: ChartPeriod) { fun selectPeriod(newPeriod: ChartPeriod) {
@@ -49,6 +53,12 @@ class ChartsViewModel @Inject constructor(
loadCharts() loadCharts()
} }
fun selectGenre(genre: String?) {
if (_selectedGenre.value == genre) return
_selectedGenre.value = genre
loadCharts()
}
fun selectTrack(trackId: String) { fun selectTrack(trackId: String) {
viewModelScope.launch { viewModelScope.launch {
_isLoadingStats.value = true _isLoadingStats.value = true
@@ -91,18 +101,19 @@ class ChartsViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
_isLoadingCharts.value = true _isLoadingCharts.value = true
try { try {
val result = chartsRepository.getCharts(_period.value) _charts.value = chartsRepository.getCharts(_period.value, _selectedGenre.value)
_charts.value = result
// Определяем, это превью или реальные данные.
// TODO: убрать проверку, когда бэкенд отдаёт charts.
// Если список совпадает с образцом — это превью.
_isPreview.value = result == ChartsRepositoryImpl.SAMPLE_CHARTS
} catch (e: Exception) { } catch (e: Exception) {
Log.e("ChartsViewModel", "Ошибка загрузки чартов", e) Log.e("ChartsViewModel", "Ошибка загрузки чартов", e)
_isPreview.value = true _charts.value = emptyList()
} finally { } finally {
_isLoadingCharts.value = false _isLoadingCharts.value = false
} }
} }
} }
private fun loadGenres() {
viewModelScope.launch {
_genres.value = chartsRepository.getGenres()
}
}
} }