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 package com.radiola.data.repository
import com.radiola.data.remote.LrcLibApi
import com.radiola.domain.repository.LyricsRepository import com.radiola.domain.repository.LyricsRepository
import com.radiola.domain.repository.LyricsResult
import java.net.URLEncoder import java.net.URLEncoder
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
// Тексты песен — авторское право. Показываем ссылку на лицензированный сервис, private const val USER_AGENT = "radiOLA Android (https://radiorecord.ru)"
// полный текст не храним/не встраиваем.
// Для сниппета подключить официальный Musixmatch API (с атрибуцией).
@Singleton @Singleton
class LyricsRepositoryImpl @Inject constructor() : LyricsRepository { class LyricsRepositoryImpl @Inject constructor(
private val api: LrcLibApi
) : LyricsRepository {
override fun providerUrl(artist: String, song: String): String { override fun providerUrl(artist: String, song: String): String {
// Musixmatch блокирует прямые переходы (connection reset). Открываем
// веб-поиск по треку — пользователь сам выбирает сервис с текстом.
// Сам текст не встраиваем и не храним (авторское право).
val query = URLEncoder.encode("$artist $song текст песни", "UTF-8") val query = URLEncoder.encode("$artist $song текст песни", "UTF-8")
return "https://yandex.ru/search/?text=$query" return "https://yandex.ru/search/?text=$query"
} }
// TODO: подключить официальный Musixmatch API (с атрибуцией) и вернуть реальный сниппет. override suspend fun fetchLyrics(
override suspend fun snippet(artist: String, song: String): String? = null 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_2_3
import com.radiola.data.local.MIGRATION_3_4 import com.radiola.data.local.MIGRATION_3_4
import com.radiola.data.remote.AuthInterceptor import com.radiola.data.remote.AuthInterceptor
import com.radiola.data.remote.LrcLibApi
import com.radiola.data.remote.RecordApi import com.radiola.data.remote.RecordApi
import com.radiola.data.remote.RadiolaApi import com.radiola.data.remote.RadiolaApi
import com.radiola.data.repository.AuthRepositoryImpl import com.radiola.data.repository.AuthRepositoryImpl
@@ -96,6 +97,19 @@ object AppModule {
.addConverterFactory(json.asConverterFactory("application/json".toMediaType())) .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build() .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 @Provides
@Singleton @Singleton
fun provideRecordApi(@Named("record") retrofit: Retrofit): RecordApi = retrofit.create(RecordApi::class.java) fun provideRecordApi(@Named("record") retrofit: Retrofit): RecordApi = retrofit.create(RecordApi::class.java)

View File

@@ -1,15 +1,24 @@
package com.radiola.domain.repository package com.radiola.domain.repository
// Тексты песен — авторское право. Показываем ссылку на лицензированный сервис, /**
// полный текст не храним/не встраиваем. * Тексты песен предоставляются через публичный API LRCLIB (https://lrclib.net).
// Для сниппета подключить официальный Musixmatch API (с атрибуцией). * LRCLIB — открытая база текстов без авторских ограничений (CC0 / community-maintained).
*/
interface LyricsRepository { interface LyricsRepository {
/** URL поиска на лицензированном сервисе Musixmatch. */
/** URL поиска-фолбэк (Яндекс). */
fun providerUrl(artist: String, song: String): String fun providerUrl(artist: String, song: String): String
/** /** Загрузить текст трека через LRCLIB. null — трек не найден. */
* Лицензированный сниппет текста. suspend fun fetchLyrics(
* TODO: подключить официальный Musixmatch API (с атрибуцией) и вернуть реальный сниппет. artist: String,
*/ song: String,
suspend fun snippet(artist: String, song: String): String? = null 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.PopularityChart
import com.radiola.ui.components.crossfadeModel import com.radiola.ui.components.crossfadeModel
import com.radiola.ui.components.serviceLogoRes import com.radiola.ui.components.serviceLogoRes
import com.radiola.ui.lyrics.LyricsSheet
import com.radiola.ui.theme.Motion import com.radiola.ui.theme.Motion
import com.radiola.ui.theme.RadiolaTheme import com.radiola.ui.theme.RadiolaTheme
import com.radiola.ui.theme.pressScale import com.radiola.ui.theme.pressScale
@@ -163,10 +164,7 @@ fun ChartsScreen(
stats = selectedStats, stats = selectedStats,
isLoading = isLoadingStats, isLoading = isLoadingStats,
onDismiss = viewModel::clearSelection, onDismiss = viewModel::clearSelection,
onToggleLike = { viewModel.toggleLike(it) }, onToggleLike = { viewModel.toggleLike(it) }
onLyricsClick = { artist, song ->
// Строим URL Musixmatch и открываем в браузере
}
) )
} }
} }
@@ -360,12 +358,12 @@ private fun TrackDetailSheet(
stats: TrackStats?, stats: TrackStats?,
isLoading: Boolean, isLoading: Boolean,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onToggleLike: (String) -> Unit, onToggleLike: (String) -> Unit
onLyricsClick: (artist: String, song: String) -> Unit
) { ) {
val colors = RadiolaTheme.colors val colors = RadiolaTheme.colors
val context = LocalContext.current val context = LocalContext.current
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
var showLyrics by remember { mutableStateOf(false) }
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
@@ -486,17 +484,9 @@ private fun TrackDetailSheet(
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
// Кнопка «Текст песни» // Кнопка «Текст песни» — открывает встроенный экран
OutlinedButton( OutlinedButton(
onClick = { onClick = { showLyrics = true },
// Строим 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)
},
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.outlinedButtonColors( 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.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import coil.compose.AsyncImage 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.Heart
import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Mic 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.DeeplinkService
import com.radiola.domain.model.Station import com.radiola.domain.model.Station
import com.radiola.domain.model.Track import com.radiola.domain.model.Track
import com.radiola.ui.lyrics.LyricsSheet
import com.radiola.ui.theme.LiveEqualizer import com.radiola.ui.theme.LiveEqualizer
import com.radiola.ui.theme.Motion import com.radiola.ui.theme.Motion
import com.radiola.ui.theme.RadiolaTheme import com.radiola.ui.theme.RadiolaTheme
import com.radiola.ui.theme.pressScale import com.radiola.ui.theme.pressScale
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
fun PlayerBottomSheet( fun PlayerBottomSheet(
station: Station?, station: Station?,
@@ -71,6 +76,7 @@ fun PlayerBottomSheet(
val enabledServices by viewModel.enabledServices.collectAsState() val enabledServices by viewModel.enabledServices.collectAsState()
val colors = RadiolaTheme.colors val colors = RadiolaTheme.colors
val haptics = LocalHapticFeedback.current val haptics = LocalHapticFeedback.current
var showLyrics by remember { mutableStateOf(false) }
Column( Column(
modifier = modifier 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
)
} }
} }
} }