Files
radiola-android/app/src/main/java/com/radiola/ui/player/PlayerViewModel.kt
nk bda2c5b30f feat(player): таймер сна с плавным затуханием (fade-out)
P0-фича из спеки. PlayerController: startSleepTimer/cancelSleepTimer — в последние
20с экспоненциальный fade-out громкости (frac^2), затем пауза + возврат громкости.
В плеере — пилюля «Таймер сна» (иконка Moon): при активном показывает остаток
M:SS акцентом. Шторка с интервалами 15/30/45/60/90/120 мин + «Выключить».

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 10:08:54 +03:00

275 lines
12 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,
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()
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) = playerController.startSleepTimer(minutes * 60_000L)
fun cancelSleepTimer() = playerController.cancelSleepTimer()
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) {
// Глушим плеер записи, если он играл — иначе два ExoPlayer'а конфликтуют
// (радио не стартует, запись зависает без управления).
recordingPlaybackController.stop()
_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(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)
}
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)
}
}
}
}