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:
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Метрики трека ----
|
||||
|
||||
125
app/src/main/java/com/radiola/ui/lyrics/LyricsSheet.kt
Normal file
125
app/src/main/java/com/radiola/ui/lyrics/LyricsSheet.kt
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
44
app/src/main/java/com/radiola/ui/lyrics/LyricsViewModel.kt
Normal file
44
app/src/main/java/com/radiola/ui/lyrics/LyricsViewModel.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user