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

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