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:
24
app/src/main/java/com/radiola/data/remote/LrcLibApi.kt
Normal file
24
app/src/main/java/com/radiola/data/remote/LrcLibApi.kt
Normal 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>
|
||||||
|
}
|
||||||
16
app/src/main/java/com/radiola/data/remote/dto/LrcLibDto.kt
Normal file
16
app/src/main/java/com/radiola/data/remote/dto/LrcLibDto.kt
Normal 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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Метрики трека ----
|
// ---- Метрики трека ----
|
||||||
|
|||||||
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.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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user