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

@@ -2,9 +2,11 @@ package com.radiola.data.remote
import com.radiola.data.remote.dto.AuthResponseDto
import com.radiola.data.remote.dto.BackendStationDto
import com.radiola.data.remote.dto.ChartsResponseDto
import com.radiola.data.remote.dto.HistoryResponseDto
import com.radiola.data.remote.dto.MagicLinkRequestDto
import com.radiola.data.remote.dto.MagicLinkVerifyDto
import com.radiola.data.remote.dto.TrackStatsDto
import com.radiola.data.remote.dto.UserSettingsDto
import kotlinx.serialization.json.JsonObject
import retrofit2.http.Body
@@ -13,6 +15,7 @@ import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.PATCH
import retrofit2.http.Path
import retrofit2.http.Query
interface RadiolaApi {
@@ -45,4 +48,21 @@ interface RadiolaApi {
@POST("users/me/history/{stationId}")
suspend fun addHistory(@Path("stationId") stationId: String): JsonObject
// --- Чарты ---
@GET("charts/tracks")
suspend fun getCharts(
@Query("period") period: String,
@Query("limit") limit: Int = 100
): ChartsResponseDto
@GET("charts/tracks/{trackId}")
suspend fun getTrackStats(@Path("trackId") trackId: String): TrackStatsDto
@POST("charts/tracks/{trackId}/like")
suspend fun likeTrack(@Path("trackId") trackId: String): JsonObject
@DELETE("charts/tracks/{trackId}/like")
suspend fun unlikeTrack(@Path("trackId") trackId: String): JsonObject
}

View File

@@ -0,0 +1,60 @@
package com.radiola.data.remote.dto
import kotlinx.serialization.Serializable
/** DTO ответа чартов — список позиций. */
@Serializable
data class ChartsResponseDto(
val items: List<ChartEntryDto> = emptyList()
)
/** Одна позиция в чарте. */
@Serializable
data class ChartEntryDto(
val rank: Int,
val trackId: String,
val artist: String,
val song: String,
val coverUrl: String? = null,
val plays: Int = 0,
val stationsCount: Int = 0,
val likes: Int = 0,
val prevRank: Int? = null,
/** Направление: up | down | new | same */
val trend: String = "same"
)
/** Подробная статистика трека. */
@Serializable
data class TrackStatsDto(
val trackId: String,
val artist: String,
val song: String,
val album: String? = null,
val coverUrl: String? = null,
val releaseDate: String? = null,
val firstSeen: String? = null,
val totalPlays: Int = 0,
val totalLikes: Int = 0,
val isLiked: Boolean = false,
val currentRank: Int? = null,
val peakRank: Int? = null,
val stations: List<StationPlaysDto> = emptyList(),
val playsTimeline: List<PointDto> = emptyList(),
val likesTimeline: List<PointDto> = emptyList()
)
/** Проигрывания на конкретной станции. */
@Serializable
data class StationPlaysDto(
val stationId: Int,
val name: String,
val plays: Int
)
/** Одна точка тайм-лайна. */
@Serializable
data class PointDto(
val date: String,
val value: Int
)

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
}