feat(charts): раздел «Чарты» (клиент) + детальная страница трека с графиком
- вкладка «Чарты» в навигации; экран: периоды (День/Неделя/Месяц/Всё), ранжированный список треков (ранг, обложка, проигрывания, тренд) - детальная карточка трека: метрики, график популярности (Canvas), лайк, кнопки музыкальных сервисов, кнопка «Текст песни» (ссылка на лицензированный Musixmatch — полный текст не встраиваем, авторское право) - ChartsRepository/LyricsRepository + эндпоинты charts/* в RadiolaApi (DTO) - превью-данные пока бэкенд не отдаёт charts (помечено TODO)
This commit is contained in:
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user