feat(app): кнопка «Распознать трек» (Shazam) + история распознанных
- кнопка распознавания в плеере: видна только на музыкальных станциях без
метаданных эфира (track == null), показывает спиннер и результат через Toast
- распознанный трек отображается в плеере и пишется в ОТДЕЛЬНУЮ историю
распознанных (не дублируется в историю эфирных треков — гейт по ключу)
- экран Истории: переключатель «Треки эфира | Распознанные», два списка
- Room: таблица recognized_track (миграция 7→8), DAO/репозиторий
- ShazamRepository → POST /shazam/recognize/{stationId}, маппинг 503/400 в текст
- MusicGenres.isMusicStation — клиентский гейт (синхронизирован с бэкендом)
- bump backend submodule (модуль shazam)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -5,19 +5,26 @@ import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.composables.icons.lucide.History
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Mic
|
||||
import com.radiola.domain.model.Track
|
||||
import com.radiola.ui.components.DeeplinkBottomSheet
|
||||
import com.radiola.ui.components.EmptyState
|
||||
@@ -32,10 +39,14 @@ fun HistoryScreen(
|
||||
viewModel: HistoryViewModel = hiltViewModel()
|
||||
) {
|
||||
val history by viewModel.history.collectAsState()
|
||||
val recognized by viewModel.recognized.collectAsState()
|
||||
val searchQuery by viewModel.searchQuery.collectAsState()
|
||||
var selectedTrack by remember { mutableStateOf<Track?>(null) }
|
||||
var tab by remember { mutableStateOf(0) }
|
||||
val colors = RadiolaTheme.colors
|
||||
|
||||
val items = if (tab == 0) history else recognized
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
@@ -51,6 +62,30 @@ fun HistoryScreen(
|
||||
modifier = Modifier.padding(top = 20.dp, bottom = 16.dp)
|
||||
)
|
||||
|
||||
// Переключатель вкладок: Треки эфира / Распознанные
|
||||
Row(
|
||||
modifier = Modifier.padding(bottom = 14.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
listOf("Треки эфира", "Распознанные").forEachIndexed { index, label ->
|
||||
val selected = tab == index
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = if (selected) colors.bgBase else colors.textSecondary,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(if (selected) colors.accent else colors.surface2)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) { tab = index }
|
||||
.padding(horizontal = 16.dp, vertical = 9.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SearchBar(
|
||||
query = searchQuery,
|
||||
onQueryChange = viewModel::onSearchQueryChange,
|
||||
@@ -59,7 +94,7 @@ fun HistoryScreen(
|
||||
)
|
||||
|
||||
Crossfade(
|
||||
targetState = history.isEmpty(),
|
||||
targetState = items.isEmpty(),
|
||||
label = "historyState"
|
||||
) { isEmpty ->
|
||||
if (isEmpty) {
|
||||
@@ -67,18 +102,26 @@ fun HistoryScreen(
|
||||
visible = true,
|
||||
enter = fadeIn() + slideInVertically()
|
||||
) {
|
||||
EmptyState(
|
||||
message = "История пуста",
|
||||
icon = Lucide.History,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
if (tab == 1) {
|
||||
EmptyState(
|
||||
message = "Пока ничего не распознано",
|
||||
icon = Lucide.Mic,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} else {
|
||||
EmptyState(
|
||||
message = "История пуста",
|
||||
icon = Lucide.History,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 16.dp)
|
||||
) {
|
||||
items(history) { track ->
|
||||
items(items) { track ->
|
||||
TrackListItem(
|
||||
track = track,
|
||||
onClick = { selectedTrack = track }
|
||||
|
||||
@@ -3,37 +3,36 @@ package com.radiola.ui.history
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.radiola.domain.model.Track
|
||||
import com.radiola.domain.repository.RecognizedTrackRepository
|
||||
import com.radiola.domain.repository.TrackHistoryRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class HistoryViewModel @Inject constructor(
|
||||
private val trackHistoryRepository: TrackHistoryRepository
|
||||
private val trackHistoryRepository: TrackHistoryRepository,
|
||||
private val recognizedTrackRepository: RecognizedTrackRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _searchQuery = MutableStateFlow("")
|
||||
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
|
||||
|
||||
val history: StateFlow<List<Track>> = combine(
|
||||
trackHistoryRepository.getHistory(),
|
||||
_searchQuery
|
||||
) { tracks, query ->
|
||||
if (query.isBlank()) tracks else tracks.filter {
|
||||
it.artist.contains(query, ignoreCase = true) ||
|
||||
it.song.contains(query, ignoreCase = true)
|
||||
}
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
private fun filtered(source: Flow<List<Track>>): StateFlow<List<Track>> =
|
||||
combine(source, _searchQuery) { tracks, query ->
|
||||
if (query.isBlank()) tracks else tracks.filter {
|
||||
it.artist.contains(query, ignoreCase = true) ||
|
||||
it.song.contains(query, ignoreCase = true)
|
||||
}
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
|
||||
// Треки эфира (как игрались на станциях)
|
||||
val history: StateFlow<List<Track>> = filtered(trackHistoryRepository.getHistory())
|
||||
|
||||
// Распознанные через Shazam
|
||||
val recognized: StateFlow<List<Track>> = filtered(recognizedTrackRepository.getHistory())
|
||||
|
||||
fun onSearchQueryChange(query: String) {
|
||||
_searchQuery.value = query
|
||||
}
|
||||
|
||||
fun removeTrack(track: Track) {
|
||||
viewModelScope.launch {
|
||||
trackHistoryRepository.removeTrack(track)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,8 +79,15 @@ fun PlayerBottomSheet(
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val enabledServices by viewModel.enabledServices.collectAsState()
|
||||
val recognizing by viewModel.recognizing.collectAsState()
|
||||
val colors = RadiolaTheme.colors
|
||||
val haptics = LocalHapticFeedback.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.recognizeEvent.collect { msg ->
|
||||
android.widget.Toast.makeText(context, msg, android.widget.Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
var showLyrics by remember { mutableStateOf(false) }
|
||||
var showQuality by remember { mutableStateOf(false) }
|
||||
var showSleep by remember { mutableStateOf(false) }
|
||||
@@ -186,6 +193,54 @@ fun PlayerBottomSheet(
|
||||
}
|
||||
}
|
||||
|
||||
// Кнопка распознавания (Shazam) — только для музыкальных станций без метаданных эфира.
|
||||
val recognizeSection: @Composable () -> Unit = {
|
||||
val show = station != null &&
|
||||
track == null &&
|
||||
com.radiola.domain.model.MusicGenres.isMusicStation(station.genre)
|
||||
if (show) {
|
||||
val interaction = remember { MutableInteractionSource() }
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(colors.accent.copy(alpha = 0.15f))
|
||||
.pressScale(interactionSource = interaction)
|
||||
.clickable(
|
||||
interactionSource = interaction,
|
||||
indication = null,
|
||||
enabled = !recognizing
|
||||
) {
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
viewModel.recognizeCurrentTrack()
|
||||
}
|
||||
.padding(horizontal = 18.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
if (recognizing) {
|
||||
CircularProgressIndicator(
|
||||
color = colors.accent,
|
||||
strokeWidth = 2.dp,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Lucide.Mic,
|
||||
contentDescription = null,
|
||||
tint = colors.accent,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = if (recognizing) "Распознаём…" else "Распознать трек",
|
||||
color = colors.accent,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val visualizerSection: @Composable () -> Unit = {
|
||||
// Живой эквалайзер. Спектр (45/с) собирается ВНУТРИ VisualizerHost —
|
||||
// чтобы 45/с рекомпозиции не задевали весь плеер, только этот leaf.
|
||||
@@ -408,6 +463,7 @@ fun PlayerBottomSheet(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
trackInfoSection()
|
||||
recognizeSection()
|
||||
Spacer(Modifier.height(16.dp))
|
||||
visualizerSection()
|
||||
Spacer(Modifier.height(16.dp))
|
||||
@@ -441,6 +497,7 @@ fun PlayerBottomSheet(
|
||||
coverSection(190.dp)
|
||||
Spacer(Modifier.height(14.dp))
|
||||
trackInfoSection()
|
||||
recognizeSection()
|
||||
Spacer(Modifier.height(20.dp))
|
||||
visualizerSection()
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
@@ -13,6 +13,9 @@ import com.radiola.domain.repository.RecordingRepository
|
||||
import com.radiola.domain.usecase.GetNowPlayingUseCase
|
||||
import com.radiola.domain.usecase.GetStationsUseCase
|
||||
import com.radiola.domain.usecase.SearchTrackInServiceUseCase
|
||||
import com.radiola.domain.repository.RecognizeResult
|
||||
import com.radiola.domain.repository.RecognizedTrackRepository
|
||||
import com.radiola.domain.repository.ShazamRepository
|
||||
import com.radiola.domain.repository.TrackHistoryRepository
|
||||
import com.radiola.domain.usecase.ToggleFavoriteUseCase
|
||||
import com.radiola.domain.usecase.auth.PushHistoryUseCase
|
||||
@@ -34,6 +37,8 @@ class PlayerViewModel @Inject constructor(
|
||||
private val searchTrackInServiceUseCase: SearchTrackInServiceUseCase,
|
||||
private val toggleFavoriteUseCase: ToggleFavoriteUseCase,
|
||||
private val trackHistoryRepository: TrackHistoryRepository,
|
||||
private val recognizedTrackRepository: RecognizedTrackRepository,
|
||||
private val shazamRepository: ShazamRepository,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val recordingRepository: RecordingRepository,
|
||||
private val pushHistoryUseCase: PushHistoryUseCase,
|
||||
@@ -54,6 +59,17 @@ class PlayerViewModel @Inject constructor(
|
||||
private val _currentTrack = MutableStateFlow<Track?>(null)
|
||||
val currentTrack: StateFlow<Track?> = _currentTrack.asStateFlow()
|
||||
|
||||
// Распознавание трека (Shazam) — индикатор и одноразовые сообщения для UI.
|
||||
private val _recognizing = MutableStateFlow(false)
|
||||
val recognizing: StateFlow<Boolean> = _recognizing.asStateFlow()
|
||||
|
||||
private val _recognizeEvent = MutableSharedFlow<String>(extraBufferCapacity = 1)
|
||||
val recognizeEvent: SharedFlow<String> = _recognizeEvent.asSharedFlow()
|
||||
|
||||
// Ключ трека, добавленного через распознавание — его НЕ дублируем в историю
|
||||
// «эфирных» треков (он идёт в отдельную историю распознанных).
|
||||
private var recognizedKey: String? = null
|
||||
|
||||
private val _enabledServices = MutableStateFlow<List<DeeplinkService>>(emptyList())
|
||||
val enabledServices: StateFlow<List<DeeplinkService>> = _enabledServices.asStateFlow()
|
||||
|
||||
@@ -103,6 +119,8 @@ class PlayerViewModel @Inject constructor(
|
||||
.filterNotNull()
|
||||
.distinctUntilChanged()
|
||||
.collect { track ->
|
||||
// Распознанный трек уже в истории распознанных — не дублируем в эфирную.
|
||||
if (trackKey(track) == recognizedKey) return@collect
|
||||
trackHistoryRepository.addTrack(track)
|
||||
}
|
||||
}
|
||||
@@ -114,6 +132,7 @@ class PlayerViewModel @Inject constructor(
|
||||
recordingPlaybackController.stop()
|
||||
_currentStation.value = station
|
||||
_currentTrack.value = null
|
||||
recognizedKey = null
|
||||
_playlist.value = playlist ?: _stations.value
|
||||
// Выбираем стартовое качество: предпочтение пользователя → совпадение с
|
||||
// потоком по умолчанию → высшее. Если вариантов нет — играем как есть.
|
||||
@@ -264,6 +283,32 @@ class PlayerViewModel @Inject constructor(
|
||||
return searchTrackInServiceUseCase(track, service)
|
||||
}
|
||||
|
||||
/** Распознать играющий сейчас трек через Shazam (бэкенд тянет аудио из потока). */
|
||||
fun recognizeCurrentTrack() {
|
||||
val station = _currentStation.value ?: return
|
||||
if (_recognizing.value) return
|
||||
_recognizing.value = true
|
||||
viewModelScope.launch {
|
||||
when (val r = shazamRepository.recognize(station.id, station.name)) {
|
||||
is RecognizeResult.Found -> {
|
||||
recognizedKey = trackKey(r.track)
|
||||
_currentTrack.value = r.track
|
||||
recognizedTrackRepository.addTrack(r.track)
|
||||
playerController.updateMetadata(
|
||||
r.track.song, r.track.artist, r.track.coverUrl ?: "", station.name
|
||||
)
|
||||
_recognizeEvent.emit("Распознано: ${r.track.artist} — ${r.track.song}")
|
||||
}
|
||||
is RecognizeResult.NotFound -> _recognizeEvent.emit("Не удалось распознать трек")
|
||||
is RecognizeResult.Error -> _recognizeEvent.emit(r.message)
|
||||
}
|
||||
_recognizing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun trackKey(t: Track): String =
|
||||
(t.artist.trim() + "|" + t.song.trim()).lowercase()
|
||||
|
||||
fun toggleFavorite(station: Station) {
|
||||
viewModelScope.launch {
|
||||
toggleFavoriteUseCase(station)
|
||||
|
||||
Reference in New Issue
Block a user