feat(lyrics): тексты песен внутри приложения через LRCLIB

- LrcLibApi (api/get + api/search, User-Agent), DI @Named(lrclib) Retrofit
- LyricsRepository.fetchLyrics -> LyricsResult (plain/synced/instrumental)
- LyricsViewModel + LyricsSheet (загрузка/инструментал/найдено/не найдено),
  прокрутка + атрибуция LRCLIB
- кнопка «Текст песни» открывает встроенный экран (плеер + деталь трека чартов),
  вместо ссылки в браузере
This commit is contained in:
nk
2026-06-03 11:47:00 +03:00
parent 5fd97d27fd
commit ba32973beb
9 changed files with 355 additions and 35 deletions

View File

@@ -1,24 +1,66 @@
package com.radiola.data.repository
import com.radiola.data.remote.LrcLibApi
import com.radiola.domain.repository.LyricsRepository
import com.radiola.domain.repository.LyricsResult
import java.net.URLEncoder
import javax.inject.Inject
import javax.inject.Singleton
// Тексты песен — авторское право. Показываем ссылку на лицензированный сервис,
// полный текст не храним/не встраиваем.
// Для сниппета подключить официальный Musixmatch API (с атрибуцией).
private const val USER_AGENT = "radiOLA Android (https://radiorecord.ru)"
@Singleton
class LyricsRepositoryImpl @Inject constructor() : LyricsRepository {
class LyricsRepositoryImpl @Inject constructor(
private val api: LrcLibApi
) : LyricsRepository {
override fun providerUrl(artist: String, song: String): String {
// Musixmatch блокирует прямые переходы (connection reset). Открываем
// веб-поиск по треку — пользователь сам выбирает сервис с текстом.
// Сам текст не встраиваем и не храним (авторское право).
val query = URLEncoder.encode("$artist $song текст песни", "UTF-8")
return "https://yandex.ru/search/?text=$query"
}
// TODO: подключить официальный Musixmatch API (с атрибуцией) и вернуть реальный сниппет.
override suspend fun snippet(artist: String, song: String): String? = null
override suspend fun fetchLyrics(
artist: String,
song: String,
durationSec: Int?
): LyricsResult? {
val cleanArtist = artist.trim()
val cleanSong = song.trim()
if (cleanArtist.isEmpty() || cleanSong.isEmpty()) return null
return try {
// Сначала точный запрос
val dto = api.get(
userAgent = USER_AGENT,
artistName = cleanArtist,
trackName = cleanSong,
durationSec = durationSec
)
LyricsResult(
plain = dto.plainLyrics?.takeIf { it.isNotBlank() },
synced = dto.syncedLyrics?.takeIf { it.isNotBlank() },
instrumental = dto.instrumental
)
} catch (_: Exception) {
// Фолбэк на поиск — берём первый результат с непустым текстом
try {
val results = api.search(
userAgent = USER_AGENT,
artistName = cleanArtist,
trackName = cleanSong
)
val found = results.firstOrNull { !it.plainLyrics.isNullOrBlank() }
?: results.firstOrNull { it.instrumental }
found?.let {
LyricsResult(
plain = it.plainLyrics?.takeIf { p -> p.isNotBlank() },
synced = it.syncedLyrics?.takeIf { s -> s.isNotBlank() },
instrumental = it.instrumental
)
}
} catch (_: Exception) {
null
}
}
}
}