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

View File

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

View File

@@ -25,28 +25,29 @@ class ChartsRepositoryImpl @Inject constructor(
private val api: RadiolaApi
) : ChartsRepository {
// TODO: убрать превью, когда бэкенд отдаёт charts
override suspend fun getCharts(period: ChartPeriod): List<ChartEntry> {
override suspend fun getCharts(period: ChartPeriod, genre: String?): List<ChartEntry> {
return try {
val response = api.getCharts(period.apiValue)
val response = api.getCharts(period.apiValue, genre = genre)
response.items.map { it.toDomain() }
} catch (e: Exception) {
Log.w("ChartsRepository", "Эндпоинт charts недоступен, возвращаем превью-данные: ${e.message}")
SAMPLE_CHARTS
Log.w("ChartsRepository", "Ошибка загрузки чартов: ${e.message}")
emptyList()
}
}
// TODO: убрать превью, когда бэкенд отдаёт charts
override suspend fun getTrackStats(trackId: String): TrackStats {
override suspend fun getGenres(): List<String> {
return try {
val dto = api.getTrackStats(trackId)
dto.toDomain()
api.getGenres().genres
} catch (e: Exception) {
Log.w("ChartsRepository", "Эндпоинт getTrackStats недоступен, возвращаем превью-данные: ${e.message}")
sampleStats(trackId)
Log.w("ChartsRepository", "Ошибка загрузки жанров: ${e.message}")
emptyList()
}
}
override suspend fun getTrackStats(trackId: String): TrackStats {
return api.getTrackStats(trackId).toDomain()
}
override suspend fun setLiked(trackId: String, liked: Boolean) {
try {
if (liked) api.likeTrack(trackId) else api.unlikeTrack(trackId)
@@ -63,6 +64,10 @@ class ChartsRepositoryImpl @Inject constructor(
artist = artist,
song = song,
coverUrl = coverUrl,
genre = genre,
styles = styles,
label = label,
year = year,
plays = plays,
stationsCount = stationsCount,
likes = likes,
@@ -81,6 +86,10 @@ class ChartsRepositoryImpl @Inject constructor(
song = song,
album = album,
coverUrl = coverUrl,
genre = genre,
styles = styles,
label = label,
year = year,
releaseDate = releaseDate,
firstSeen = firstSeen,
totalPlays = totalPlays,
@@ -111,87 +120,4 @@ class ChartsRepositoryImpl @Inject constructor(
}
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 song: String,
val coverUrl: String?,
val genre: String?,
val styles: List<String>,
val label: String?,
val year: Int?,
val plays: Int,
val stationsCount: Int,
val likes: Int,

View File

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

View File

@@ -5,7 +5,8 @@ import com.radiola.domain.model.ChartPeriod
import com.radiola.domain.model.TrackStats
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 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.basicMarquee
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
@@ -61,7 +63,8 @@ fun ChartsScreen(
val colors = RadiolaTheme.colors
val period by viewModel.period.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 selectedStats by viewModel.selectedTrackStats.collectAsState()
val isLoadingStats by viewModel.isLoadingStats.collectAsState()
@@ -95,35 +98,18 @@ fun ChartsScreen(
onSelect = viewModel::selectPeriod
)
Spacer(Modifier.height(12.dp))
// Плашка «Демо-данные»
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
)
}
// Фильтр по жанру (если бэкенд уже накопил жанры)
if (genres.isNotEmpty()) {
Spacer(Modifier.height(10.dp))
GenreSelector(
genres = genres,
selected = selectedGenre,
onSelect = viewModel::selectGenre
)
}
Spacer(Modifier.height(12.dp))
// Список чартов
if (isLoadingCharts) {
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)
@@ -436,15 +451,32 @@ private fun TrackDetailSheet(
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 = "Вышел: ${stats.releaseDate}",
text = metaLine,
style = MaterialTheme.typography.labelSmall,
color = colors.textMuted,
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))
// Ряд метрик
@@ -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

View File

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