Обложки наливались общей очередью (1.5с) — играющий трек ждал свою очередь. Добавлена приоритетная дорожка: трек, который слушают сейчас, обогащается первым (PlayerViewModel → NowPlayingRepository.enrichCoverNow). Троттл общей очереди ускорен 1.5с→0.8с. Дедуп разнесён на enqueued/processed, чтобы дорожки не дублировали работу. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
261 lines
11 KiB
Kotlin
261 lines
11 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.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
|
||
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
}
|