Files
radiola-android/app/src/main/java/com/radiola/ui/player/PlayerViewModel.kt
nk 05e5538945 feat(player): живой эквалайзер по реальному звуку (FFT-спектр)
Эквалайзер на плеере больше не декоративная синус-волна — реагирует на
реальный звук. Через TeeAudioProcessor подключаемся к декодированному PCM в
аудио-конвейере ExoPlayer (без разрешений/микрофона), считаем FFT → лог-полосы
(AudioSpectrumAnalyzer), PlayerController отдаёт спектр StateFlow'ом, LiveEqualizer
рисует столбики по уровням (с быстрым ростом/плавным спадом). Когда звука нет
(пауза/float-выход) — фолбэк на прежнюю синус-волну.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 17:39:09 +03:00

262 lines
11 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.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 settingsRepository: SettingsRepository,
private val recordingRepository: RecordingRepository,
private val pushHistoryUseCase: PushHistoryUseCase,
private val loveStreamResolver: com.radiola.data.remote.LoveStreamResolver
) : ViewModel() {
val isPlaying: StateFlow<Boolean> = playerController.isPlaying
val currentStationPrefix: StateFlow<String?> = playerController.currentStationPrefix
val spectrum: StateFlow<FloatArray> = playerController.spectrum
private val _currentStation = MutableStateFlow<Station?>(null)
val currentStation: StateFlow<Station?> = _currentStation.asStateFlow()
private val _currentTrack = MutableStateFlow<Track?>(null)
val currentTrack: StateFlow<Track?> = _currentTrack.asStateFlow()
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
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 ->
trackHistoryRepository.addTrack(track)
}
}
}
fun play(station: Station, playlist: List<Station>? = null) {
_currentStation.value = station
_currentTrack.value = 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 {
// Polling loop for Record API now playing
launch {
while (true) {
nowPlayingRepository.refreshNowPlaying()
delay(10_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)
}
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)
}
}
}
}