Опрос now-playing был привязан к play()/жизни ViewModel — при заморозке ColorOS в фоне или пересоздании ViewModel трек «застывал». Теперь: startNowPlaying() с мгновенным refresh, восстановление привязки к играющей станции из PlayerController.currentStationId, и onAppForeground() на ON_RESUME.
370 lines
17 KiB
Kotlin
370 lines
17 KiB
Kotlin
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 &&
|
||
// SOVA (сторонний мод ВК) — только в sideload-сборке.
|
||
(com.radiola.BuildConfig.SHOW_DEV_TOOLS || it != DeeplinkService.SOVA)
|
||
}
|
||
}
|
||
}
|
||
viewModelScope.launch {
|
||
settingsRepository.getPreferredBitrate().collect { preferredBitrate = it }
|
||
}
|
||
// Восстановление сессии: если процесс/Activity пересоздались, а станция уже
|
||
// играет в фоновом сервисе (PlayerController помнит id) — заново привязываемся
|
||
// и запускаем опрос now-playing. Иначе мини-плеер/эфир «застывают».
|
||
viewModelScope.launch {
|
||
combine(playerController.currentStationId, _stations) { id, list ->
|
||
id?.let { sid -> list.firstOrNull { it.id == sid } }
|
||
}.collect { station ->
|
||
if (station != null && _currentStation.value == null) {
|
||
_currentStation.value = station
|
||
_playlist.value = _stations.value
|
||
startNowPlaying(station)
|
||
}
|
||
}
|
||
}
|
||
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) }
|
||
startNowPlaying(station)
|
||
}
|
||
|
||
/**
|
||
* Запускает опрос now-playing для станции: мгновенный рефреш + цикл раз в 5с
|
||
* (пока играем) + сбор трека из API (приоритет) и ICY (фолбэк). Вынесено из
|
||
* play(), чтобы переиспользовать при восстановлении сессии (возврат из фона /
|
||
* пересоздание ViewModel) — иначе эфир «застывает» на последнем значении.
|
||
*/
|
||
private fun startNowPlaying(station: Station) {
|
||
nowPlayingJob?.cancel()
|
||
nowPlayingJob = viewModelScope.launch {
|
||
// Сразу тянем свежий эфир — не ждём первые 5с цикла.
|
||
launch { nowPlayingRepository.refreshNowPlaying() }
|
||
// Поллинг 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
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Возврат приложения на передний план: мгновенно освежаем эфир (чтобы юзер не
|
||
* видел залипший трек после фоновой заморозки) и, если опрос почему-то не идёт,
|
||
* перезапускаем его для текущей станции.
|
||
*/
|
||
fun onAppForeground() {
|
||
val station = _currentStation.value ?: return
|
||
viewModelScope.launch { nowPlayingRepository.refreshNowPlaying() }
|
||
if (nowPlayingJob?.isActive != true) startNowPlaying(station)
|
||
}
|
||
|
||
/** Стартовое качество станции с учётом предпочтения пользователя. */
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
}
|