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

@@ -0,0 +1,24 @@
package com.radiola.data.remote
import com.radiola.data.remote.dto.LrcLibLyricsDto
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Query
interface LrcLibApi {
@GET("api/get")
suspend fun get(
@Header("User-Agent") userAgent: String = "radiOLA Android (https://radiorecord.ru)",
@Query("artist_name") artistName: String,
@Query("track_name") trackName: String,
@Query("duration") durationSec: Int? = null
): LrcLibLyricsDto
@GET("api/search")
suspend fun search(
@Header("User-Agent") userAgent: String = "radiOLA Android (https://radiorecord.ru)",
@Query("artist_name") artistName: String,
@Query("track_name") trackName: String
): List<LrcLibLyricsDto>
}

View File

@@ -0,0 +1,16 @@
package com.radiola.data.remote.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class LrcLibLyricsDto(
@SerialName("id") val id: Int? = null,
@SerialName("trackName") val trackName: String? = null,
@SerialName("artistName") val artistName: String? = null,
@SerialName("albumName") val albumName: String? = null,
@SerialName("duration") val duration: Double? = null,
@SerialName("instrumental") val instrumental: Boolean = false,
@SerialName("plainLyrics") val plainLyrics: String? = null,
@SerialName("syncedLyrics") val syncedLyrics: String? = null
)

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
}
}
}
}

View File

@@ -8,6 +8,7 @@ import com.radiola.data.local.MIGRATION_1_2
import com.radiola.data.local.MIGRATION_2_3
import com.radiola.data.local.MIGRATION_3_4
import com.radiola.data.remote.AuthInterceptor
import com.radiola.data.remote.LrcLibApi
import com.radiola.data.remote.RecordApi
import com.radiola.data.remote.RadiolaApi
import com.radiola.data.repository.AuthRepositoryImpl
@@ -96,6 +97,19 @@ object AppModule {
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
@Provides
@Singleton
@Named("lrclib")
fun provideLrcLibRetrofit(okHttpClient: OkHttpClient, json: Json): Retrofit = Retrofit.Builder()
.baseUrl("https://lrclib.net/")
.client(okHttpClient)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
@Provides
@Singleton
fun provideLrcLibApi(@Named("lrclib") retrofit: Retrofit): LrcLibApi = retrofit.create(LrcLibApi::class.java)
@Provides
@Singleton
fun provideRecordApi(@Named("record") retrofit: Retrofit): RecordApi = retrofit.create(RecordApi::class.java)

View File

@@ -1,15 +1,24 @@
package com.radiola.domain.repository
// Тексты песен — авторское право. Показываем ссылку на лицензированный сервис,
// полный текст не храним/не встраиваем.
// Для сниппета подключить официальный Musixmatch API (с атрибуцией).
/**
* Тексты песен предоставляются через публичный API LRCLIB (https://lrclib.net).
* LRCLIB — открытая база текстов без авторских ограничений (CC0 / community-maintained).
*/
interface LyricsRepository {
/** URL поиска на лицензированном сервисе Musixmatch. */
/** URL поиска-фолбэк (Яндекс). */
fun providerUrl(artist: String, song: String): String
/**
* Лицензированный сниппет текста.
* TODO: подключить официальный Musixmatch API (с атрибуцией) и вернуть реальный сниппет.
*/
suspend fun snippet(artist: String, song: String): String? = null
/** Загрузить текст трека через LRCLIB. null — трек не найден. */
suspend fun fetchLyrics(
artist: String,
song: String,
durationSec: Int? = null
): LyricsResult?
}
data class LyricsResult(
val plain: String?,
val synced: String?,
val instrumental: Boolean
)

View File

@@ -46,6 +46,7 @@ import com.radiola.ui.components.EmptyState
import com.radiola.ui.components.PopularityChart
import com.radiola.ui.components.crossfadeModel
import com.radiola.ui.components.serviceLogoRes
import com.radiola.ui.lyrics.LyricsSheet
import com.radiola.ui.theme.Motion
import com.radiola.ui.theme.RadiolaTheme
import com.radiola.ui.theme.pressScale
@@ -163,10 +164,7 @@ fun ChartsScreen(
stats = selectedStats,
isLoading = isLoadingStats,
onDismiss = viewModel::clearSelection,
onToggleLike = { viewModel.toggleLike(it) },
onLyricsClick = { artist, song ->
// Строим URL Musixmatch и открываем в браузере
}
onToggleLike = { viewModel.toggleLike(it) }
)
}
}
@@ -360,12 +358,12 @@ private fun TrackDetailSheet(
stats: TrackStats?,
isLoading: Boolean,
onDismiss: () -> Unit,
onToggleLike: (String) -> Unit,
onLyricsClick: (artist: String, song: String) -> Unit
onToggleLike: (String) -> Unit
) {
val colors = RadiolaTheme.colors
val context = LocalContext.current
val haptic = LocalHapticFeedback.current
var showLyrics by remember { mutableStateOf(false) }
ModalBottomSheet(
onDismissRequest = onDismiss,
@@ -486,17 +484,9 @@ private fun TrackDetailSheet(
Spacer(Modifier.height(20.dp))
// Кнопка «Текст песни»
// Кнопка «Текст песни» — открывает встроенный экран
OutlinedButton(
onClick = {
// Строим URL Musixmatch и открываем в браузере
val query = java.net.URLEncoder.encode(
"${stats.artist} ${stats.song}", "UTF-8"
)
val url = "https://www.musixmatch.com/search/$query"
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
context.startActivity(intent)
},
onClick = { showLyrics = true },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.outlinedButtonColors(
@@ -542,6 +532,20 @@ private fun TrackDetailSheet(
}
}
}
// Шторка текста песни поверх детальной карточки
if (showLyrics && stats != null) {
ModalBottomSheet(
onDismissRequest = { showLyrics = false },
containerColor = colors.bgBase,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
) {
LyricsSheet(
artist = stats.artist,
song = stats.song
)
}
}
}
// ---- Метрики трека ----

View File

@@ -0,0 +1,125 @@
package com.radiola.ui.lyrics
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.radiola.ui.theme.RadiolaTheme
/**
* Содержимое шторки текста песни. Данные загружаются через LyricsViewModel → LRCLIB API.
* Встраивается в ModalBottomSheet на стороне вызывающего экрана.
*/
@Composable
fun LyricsSheet(
artist: String,
song: String,
durationSec: Int? = null,
viewModel: LyricsViewModel = hiltViewModel()
) {
val colors = RadiolaTheme.colors
val state by viewModel.state.collectAsState()
LaunchedEffect(artist, song) {
viewModel.load(artist, song, durationSec)
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp)
) {
// Заголовок
Text(
text = song,
style = MaterialTheme.typography.titleLarge,
color = colors.textPrimary,
maxLines = 2
)
Spacer(Modifier.height(4.dp))
Text(
text = artist,
style = MaterialTheme.typography.bodyMedium,
color = colors.textSecondary,
maxLines = 1
)
Spacer(Modifier.height(20.dp))
// Состояния
when (val s = state) {
is LyricsState.Loading -> {
Box(
modifier = Modifier
.fillMaxWidth()
.height(180.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(color = colors.accent)
}
}
is LyricsState.Instrumental -> {
Text(
text = "Инструментальная композиция",
style = MaterialTheme.typography.bodyLarge,
color = colors.textMuted,
modifier = Modifier.padding(vertical = 40.dp)
)
}
is LyricsState.Found -> {
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 480.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
s.plain.lines().forEach { line ->
Text(
text = line.ifEmpty { " " },
style = MaterialTheme.typography.bodyLarge,
color = colors.textPrimary,
lineHeight = MaterialTheme.typography.bodyLarge.lineHeight * 1.4f
)
}
}
}
is LyricsState.NotFound -> {
Text(
text = "Текст не найден",
style = MaterialTheme.typography.bodyLarge,
color = colors.textMuted,
modifier = Modifier.padding(vertical = 40.dp)
)
}
is LyricsState.Error -> {
Text(
text = "Не удалось загрузить текст",
style = MaterialTheme.typography.bodyLarge,
color = colors.textMuted,
modifier = Modifier.padding(vertical = 40.dp)
)
}
}
Spacer(Modifier.height(12.dp))
// Атрибуция LRCLIB
Text(
text = "Тексты: LRCLIB",
style = MaterialTheme.typography.labelSmall,
color = colors.textMuted,
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(bottom = 16.dp)
)
}
}

View File

@@ -0,0 +1,44 @@
package com.radiola.ui.lyrics
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.radiola.domain.repository.LyricsRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
sealed interface LyricsState {
data object Loading : LyricsState
data object Instrumental : LyricsState
data class Found(val plain: String) : LyricsState
data object NotFound : LyricsState
data object Error : LyricsState
}
@HiltViewModel
class LyricsViewModel @Inject constructor(
private val repository: LyricsRepository
) : ViewModel() {
private val _state = MutableStateFlow<LyricsState>(LyricsState.Loading)
val state: StateFlow<LyricsState> = _state
fun load(artist: String, song: String, durationSec: Int? = null) {
viewModelScope.launch {
_state.value = LyricsState.Loading
try {
val result = repository.fetchLyrics(artist, song, durationSec)
_state.value = when {
result == null -> LyricsState.NotFound
result.instrumental -> LyricsState.Instrumental
!result.plain.isNullOrBlank() -> LyricsState.Found(result.plain)
else -> LyricsState.NotFound
}
} catch (_: Exception) {
_state.value = LyricsState.Error
}
}
}
}

View File

@@ -32,6 +32,10 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import coil.compose.AsyncImage
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState
import com.composables.icons.lucide.FileText
import com.composables.icons.lucide.Heart
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Mic
@@ -46,12 +50,13 @@ import com.radiola.deeplink.DeeplinkNavigator
import com.radiola.domain.model.DeeplinkService
import com.radiola.domain.model.Station
import com.radiola.domain.model.Track
import com.radiola.ui.lyrics.LyricsSheet
import com.radiola.ui.theme.LiveEqualizer
import com.radiola.ui.theme.Motion
import com.radiola.ui.theme.RadiolaTheme
import com.radiola.ui.theme.pressScale
@OptIn(ExperimentalFoundationApi::class)
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
fun PlayerBottomSheet(
station: Station?,
@@ -71,6 +76,7 @@ fun PlayerBottomSheet(
val enabledServices by viewModel.enabledServices.collectAsState()
val colors = RadiolaTheme.colors
val haptics = LocalHapticFeedback.current
var showLyrics by remember { mutableStateOf(false) }
Column(
modifier = modifier
@@ -265,6 +271,42 @@ fun PlayerBottomSheet(
)
}
}
Spacer(Modifier.height(12.dp))
}
// Кнопка «Текст песни» — активна только когда играет трек
if (track != null) {
TextButton(
onClick = { showLyrics = true },
modifier = Modifier.align(Alignment.CenterHorizontally)
) {
Icon(
imageVector = Lucide.FileText,
contentDescription = null,
tint = colors.accent,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(6.dp))
Text(
text = "Текст песни",
color = colors.accent,
style = MaterialTheme.typography.labelLarge
)
}
}
}
// Шторка текста песни
if (showLyrics && track != null) {
ModalBottomSheet(
onDismissRequest = { showLyrics = false },
containerColor = colors.bgBase,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
) {
LyricsSheet(
artist = track.artist,
song = track.song
)
}
}
}