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 = playerController.isPlaying val currentStationPrefix: StateFlow = playerController.currentStationPrefix val spectrum: StateFlow = playerController.spectrum val visualizerStyle: StateFlow = settingsRepository.getVisualizerStyle() .stateIn(viewModelScope, kotlinx.coroutines.flow.SharingStarted.WhileSubscribed(5000), "bars_center") private val _currentStation = MutableStateFlow(null) val currentStation: StateFlow = _currentStation.asStateFlow() private val _currentTrack = MutableStateFlow(null) val currentTrack: StateFlow = _currentTrack.asStateFlow() // Распознавание трека (Shazam) — индикатор и одноразовые сообщения для UI. private val _recognizing = MutableStateFlow(false) val recognizing: StateFlow = _recognizing.asStateFlow() private val _recognizeEvent = MutableSharedFlow(extraBufferCapacity = 1) val recognizeEvent: SharedFlow = _recognizeEvent.asSharedFlow() // Ключ трека, добавленного через распознавание — его НЕ дублируем в историю // «эфирных» треков (он идёт в отдельную историю распознанных). private var recognizedKey: String? = null private val _enabledServices = MutableStateFlow>(emptyList()) val enabledServices: StateFlow> = _enabledServices.asStateFlow() private val _stations = MutableStateFlow>(emptyList()) val stations: StateFlow> = _stations.asStateFlow() private val _playlist = MutableStateFlow>(emptyList()) val playlist: StateFlow> = _playlist.asStateFlow() // Выбранное качество текущей станции (битрейт). null — у станции нет вариантов. private val _currentQuality = MutableStateFlow(null) val currentQuality: StateFlow = _currentQuality.asStateFlow() // Предпочитаемый битрейт пользователя (0 = авто/по умолчанию станции). private var preferredBitrate: Int = 0 val isRecording: StateFlow = recordingRepository.isRecording // Таймер сна: оставшееся время в мс (null = выключен). val sleepRemainingMs: StateFlow = 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? = 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) } } } }