feat(charts): раздел «Чарты» (клиент) + детальная страница трека с графиком

- вкладка «Чарты» в навигации; экран: периоды (День/Неделя/Месяц/Всё),
  ранжированный список треков (ранг, обложка, проигрывания, тренд)
- детальная карточка трека: метрики, график популярности (Canvas), лайк,
  кнопки музыкальных сервисов, кнопка «Текст песни» (ссылка на лицензированный
  Musixmatch — полный текст не встраиваем, авторское право)
- ChartsRepository/LyricsRepository + эндпоинты charts/* в RadiolaApi (DTO)
- превью-данные пока бэкенд не отдаёт charts (помечено TODO)
This commit is contained in:
nk
2026-06-02 23:24:42 +03:00
parent a4af72a6e6
commit d0e5f4e8c5
15 changed files with 1346 additions and 1 deletions

View File

@@ -0,0 +1,197 @@
package com.radiola.data.repository
import android.util.Log
import com.radiola.data.remote.RadiolaApi
import com.radiola.data.remote.dto.ChartEntryDto
import com.radiola.data.remote.dto.PointDto
import com.radiola.data.remote.dto.StationPlaysDto
import com.radiola.data.remote.dto.TrackStatsDto
import com.radiola.domain.model.ChartEntry
import com.radiola.domain.model.ChartPeriod
import com.radiola.domain.model.ChartTrend
import com.radiola.domain.model.StatPoint
import com.radiola.domain.model.StationPlays
import com.radiola.domain.model.TrackStats
import com.radiola.domain.repository.ChartsRepository
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ChartsRepositoryImpl @Inject constructor(
private val api: RadiolaApi
) : ChartsRepository {
// TODO: убрать превью, когда бэкенд отдаёт charts
override suspend fun getCharts(period: ChartPeriod): List<ChartEntry> {
return try {
val response = api.getCharts(period.apiValue)
response.items.map { it.toDomain() }
} catch (e: Exception) {
Log.w("ChartsRepository", "Эндпоинт charts недоступен, возвращаем превью-данные: ${e.message}")
SAMPLE_CHARTS
}
}
// TODO: убрать превью, когда бэкенд отдаёт charts
override suspend fun getTrackStats(trackId: String): TrackStats {
return try {
val dto = api.getTrackStats(trackId)
dto.toDomain()
} catch (e: Exception) {
Log.w("ChartsRepository", "Эндпоинт getTrackStats недоступен, возвращаем превью-данные: ${e.message}")
sampleStats(trackId)
}
}
override suspend fun setLiked(trackId: String, liked: Boolean) {
try {
if (liked) api.likeTrack(trackId) else api.unlikeTrack(trackId)
} catch (e: Exception) {
Log.w("ChartsRepository", "Ошибка лайка трека $trackId: ${e.message}")
}
}
// ---- Маппинг DTO → Domain ----
private fun ChartEntryDto.toDomain() = ChartEntry(
rank = rank,
trackId = trackId,
artist = artist,
song = song,
coverUrl = coverUrl,
plays = plays,
stationsCount = stationsCount,
likes = likes,
prevRank = prevRank,
trend = when (trend) {
"up" -> ChartTrend.UP
"down" -> ChartTrend.DOWN
"new" -> ChartTrend.NEW
else -> ChartTrend.SAME
}
)
private fun TrackStatsDto.toDomain() = TrackStats(
trackId = trackId,
artist = artist,
song = song,
album = album,
coverUrl = coverUrl,
releaseDate = releaseDate,
firstSeen = firstSeen,
totalPlays = totalPlays,
totalLikes = totalLikes,
isLiked = isLiked,
currentRank = currentRank,
peakRank = peakRank,
stations = stations.map { it.toDomain() },
playsTimeline = playsTimeline.map { it.toDomain() },
likesTimeline = likesTimeline.map { it.toDomain() }
)
private fun StationPlaysDto.toDomain() = StationPlays(stationId, name, plays)
private fun PointDto.toDomain(): StatPoint {
val epochMs = try {
Instant.parse(date).toEpochMilli()
} catch (e: Exception) {
// Попробуем как yyyy-MM-dd
try {
LocalDate.parse(date, DateTimeFormatter.ISO_LOCAL_DATE)
.atStartOfDay(ZoneOffset.UTC)
.toInstant()
.toEpochMilli()
} catch (e2: Exception) {
System.currentTimeMillis()
}
}
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

@@ -0,0 +1,21 @@
package com.radiola.data.repository
import com.radiola.domain.repository.LyricsRepository
import java.net.URLEncoder
import javax.inject.Inject
import javax.inject.Singleton
// Тексты песен — авторское право. Показываем ссылку на лицензированный сервис,
// полный текст не храним/не встраиваем.
// Для сниппета подключить официальный Musixmatch API (с атрибуцией).
@Singleton
class LyricsRepositoryImpl @Inject constructor() : LyricsRepository {
override fun providerUrl(artist: String, song: String): String {
val query = URLEncoder.encode("$artist $song", "UTF-8")
return "https://www.musixmatch.com/search/$query"
}
// TODO: подключить официальный Musixmatch API (с атрибуцией) и вернуть реальный сниппет.
override suspend fun snippet(artist: String, song: String): String? = null
}