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

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