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