Files
radiola-android/app/src/main/java/com/radiola/ui/player/PlayerViewModel.kt
nk 69682268f3 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>
2026-06-07 18:38:17 +03:00

329 lines
15 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package com.radiola.ui.player
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.radiola.domain.model.DeeplinkService
import com.radiola.domain.model.Station
import com.radiola.domain.model.StreamQuality
import com.radiola.domain.model.Track
import com.radiola.domain.repository.SettingsRepository
import com.radiola.domain.repository.StationRepository
import com.radiola.domain.repository.NowPlayingRepository
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
import com.radiola.service.PlayerController
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class PlayerViewModel @Inject constructor(
private val playerController: PlayerController,
private val stationRepository: StationRepository,
private val nowPlayingRepository: NowPlayingRepository,
private val getStationsUseCase: GetStationsUseCase,
private val getNowPlayingUseCase: GetNowPlayingUseCase,
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,
private val loveStreamResolver: com.radiola.data.remote.LoveStreamResolver,
private val recordingPlaybackController: com.radiola.service.RecordingPlaybackController
) : ViewModel() {
val isPlaying: StateFlow<Boolean> = playerController.isPlaying
val currentStationPrefix: StateFlow<String?> = playerController.currentStationPrefix
val spectrum: StateFlow<FloatArray> = playerController.spectrum
val visualizerStyle: StateFlow<String> = settingsRepository.getVisualizerStyle()
.stateIn(viewModelScope, kotlinx.coroutines.flow.SharingStarted.WhileSubscribed(5000), "bars_center")
private val _currentStation = MutableStateFlow<Station?>(null)
val currentStation: StateFlow<Station?> = _currentStation.asStateFlow()
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()
private val _stations = MutableStateFlow<List<Station>>(emptyList())
val stations: StateFlow<List<Station>> = _stations.asStateFlow()
private val _playlist = MutableStateFlow<List<Station>>(emptyList())
val playlist: StateFlow<List<Station>> = _playlist.asStateFlow()
// Выбранное качество текущей станции (битрейт). null — у станции нет вариантов.
private val _currentQuality = MutableStateFlow<StreamQuality?>(null)
val currentQuality: StateFlow<StreamQuality?> = _currentQuality.asStateFlow()
// Предпочитаемый битрейт пользователя (0 = авто/по умолчанию станции).
private var preferredBitrate: Int = 0
val isRecording: StateFlow<Boolean> = recordingRepository.isRecording
// Таймер сна: оставшееся время в мс (null = выключен).
val sleepRemainingMs: StateFlow<Long?> = playerController.sleepRemainingMs
fun startSleepTimer(minutes: Int, sound: com.radiola.service.SleepSound? = null) =
playerController.startSleepTimer(minutes * 60_000L, sound)
fun cancelSleepTimer() = playerController.cancelSleepTimer()
// Спектр (FFT) считаем только пока открыт плеер — экономия батареи в фоне.
fun setSpectrumActive(active: Boolean) = playerController.setSpectrumActive(active)
private var nowPlayingJob: Job? = null
init {
playerController.onSkipToNext = { playNext() }
playerController.onSkipToPrevious = { playPrevious() }
viewModelScope.launch {
getStationsUseCase().collect { _stations.value = it }
}
viewModelScope.launch {
settingsRepository.getEnabledDeeplinkServices().collect { ids ->
_enabledServices.value = DeeplinkService.entries.filter { it.serviceId in ids }
}
}
viewModelScope.launch {
settingsRepository.getPreferredBitrate().collect { preferredBitrate = it }
}
viewModelScope.launch {
_currentTrack
.filterNotNull()
.distinctUntilChanged()
.collect { track ->
// Распознанный трек уже в истории распознанных — не дублируем в эфирную.
if (trackKey(track) == recognizedKey) return@collect
trackHistoryRepository.addTrack(track)
}
}
}
fun play(station: Station, playlist: List<Station>? = null) {
// Глушим плеер записи, если он играл — иначе два ExoPlayer'а конфликтуют
// (радио не стартует, запись зависает без управления).
recordingPlaybackController.stop()
_currentStation.value = station
_currentTrack.value = null
recognizedKey = null
_playlist.value = playlist ?: _stations.value
// Выбираем стартовое качество: предпочтение пользователя → совпадение с
// потоком по умолчанию → высшее. Если вариантов нет — играем как есть.
val quality = pickInitialQuality(station)
_currentQuality.value = quality
val streamUrl = quality?.url ?: station.streamUrl
// Love Radio: подставляем сессионный UID (иначе поток отдаёт заглушку).
// Для остальных resolve вернёт URL как есть.
viewModelScope.launch {
val url = loveStreamResolver.resolve(streamUrl)
playerController.play(url, station.prefix, station.name, station.id)
}
viewModelScope.launch { pushHistoryUseCase(station.id) }
nowPlayingJob?.cancel()
nowPlayingJob = viewModelScope.launch {
// Поллинг now-playing — ТОЛЬКО пока играем. collectLatest отменяет
// внутренний цикл при паузе (иначе на паузе радио зря дёргали сеть
// каждые 5с → батарея + лишняя нагрузка на бэкенд).
launch {
playerController.isPlaying.collectLatest { playing ->
if (!playing) return@collectLatest
while (true) {
nowPlayingRepository.refreshNowPlaying()
delay(5_000)
}
}
}
// Collect now playing for this station (API has priority: covers + accurate metadata)
launch {
getNowPlayingUseCase(station.id)
.distinctUntilChanged()
.collect { track ->
if (track != null) {
_currentTrack.value = track
// Нет обложки — обогащаем приоритетно (играет прямо сейчас).
if (track.coverUrl.isNullOrBlank()) {
nowPlayingRepository.enrichCoverNow(track)
}
playerController.updateMetadata(
track.song,
track.artist,
track.coverUrl ?: "",
station.name
)
}
}
}
// Fallback: Icy metadata from stream for stations not in Record API
launch {
playerController.icyTitle
.filterNotNull()
.distinctUntilChanged()
.collect { icyTitle ->
// Only use Icy if no API track is currently active
if (_currentTrack.value == null) {
val track = parseIcyTitle(icyTitle)
if (track != null) {
_currentTrack.value = track
playerController.updateMetadata(
track.song,
track.artist,
"",
station.name
)
}
}
}
}
}
}
/** Стартовое качество станции с учётом предпочтения пользователя. */
private fun pickInitialQuality(station: Station): StreamQuality? {
val list = station.qualities
if (list.size < 2) return null
return list.firstOrNull { it.bitrate == preferredBitrate }
?: list.firstOrNull { it.url == station.streamUrl }
?: list.first()
}
/** Переключить качество текущей станции на лету (без сброса now-playing). */
fun selectQuality(quality: StreamQuality) {
val station = _currentStation.value ?: return
if (_currentQuality.value?.bitrate == quality.bitrate) return
_currentQuality.value = quality
preferredBitrate = quality.bitrate
viewModelScope.launch { settingsRepository.setPreferredBitrate(quality.bitrate) }
viewModelScope.launch {
val url = loveStreamResolver.resolve(quality.url)
playerController.changeStream(url)
}
}
private fun parseIcyTitle(title: String?): Track? {
if (title.isNullOrBlank()) return null
val separators = listOf(" - ", "", " ")
for (sep in separators) {
val parts = title.split(sep, limit = 2)
if (parts.size == 2) {
return Track(
artist = parts[0].trim(),
song = parts[1].trim(),
coverUrl = null,
stationName = _currentStation.value?.name ?: ""
)
}
}
// No separator found: treat entire string as song title
return Track(
artist = "",
song = title.trim(),
coverUrl = null,
stationName = _currentStation.value?.name ?: ""
)
}
fun pause() {
playerController.pause()
}
fun resume() {
playerController.play()
}
fun togglePlayPause() {
if (isPlaying.value) pause() else resume()
}
fun playNext() {
val current = _currentStation.value ?: return
val list = _playlist.value
if (list.isEmpty()) return
val index = list.indexOfFirst { it.id == current.id }
val next = list.getOrNull((index + 1).mod(list.size))
next?.let { play(it, list) }
}
fun playPrevious() {
val current = _currentStation.value ?: return
val list = _playlist.value
if (list.isEmpty()) return
val index = list.indexOfFirst { it.id == current.id }
val prev = list.getOrNull((index - 1).mod(list.size))
prev?.let { play(it, list) }
}
fun getDeeplinkUrl(track: Track, service: DeeplinkService): String {
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)
}
}
fun toggleRecording() {
viewModelScope.launch {
if (recordingRepository.isRecording.value) {
recordingRepository.stopRecording()
} else {
val station = _currentStation.value ?: return@launch
recordingRepository.startRecording(station, _currentTrack.value)
}
}
}
}