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:
nk
2026-06-07 18:38:17 +03:00
parent 251809df33
commit 69682268f3
16 changed files with 371 additions and 28 deletions

View File

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