Симптом: по Bluetooth в машине с выключенным экраном радио через время замолкало. Причины и фиксы: - setWakeMode(C.WAKE_MODE_NETWORK) + право WAKE_LOCK — ExoPlayer держит partial wakelock + wifilock во время игры. Без этого система усыпляла CPU/Wi-Fi при выключенном экране → буфер пустел → поток глох (главная причина). - onPlayerError → scheduleReconnect(): при обрыве сети (туннели, край соты) поток пере-готавливается с нарастающей задержкой (2с→15с, до 10 попыток), а не замолкает навсегда. Счётчик сбрасывается при успешном старте; переподключение отменяется при ручной паузе/стопе/смене станции.
439 lines
20 KiB
Kotlin
439 lines
20 KiB
Kotlin
package com.radiola.service
|
||
|
||
import android.content.Context
|
||
import android.media.AudioDeviceCallback
|
||
import android.media.AudioDeviceInfo
|
||
import android.media.AudioManager
|
||
import android.net.Uri
|
||
import androidx.media3.common.AudioAttributes
|
||
import androidx.media3.common.C
|
||
import androidx.media3.common.ForwardingPlayer
|
||
import androidx.media3.common.MediaItem
|
||
import androidx.media3.common.MediaMetadata
|
||
import androidx.media3.common.Metadata
|
||
import androidx.media3.common.PlaybackException
|
||
import androidx.media3.common.Player
|
||
import android.util.Log
|
||
import androidx.media3.common.util.UnstableApi
|
||
import androidx.media3.datasource.DefaultDataSource
|
||
import androidx.media3.datasource.DefaultHttpDataSource
|
||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||
import androidx.media3.exoplayer.ExoPlayer
|
||
import androidx.media3.exoplayer.audio.AudioSink
|
||
import androidx.media3.exoplayer.audio.DefaultAudioSink
|
||
import androidx.media3.exoplayer.audio.TeeAudioProcessor
|
||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||
import androidx.media3.extractor.metadata.icy.IcyInfo
|
||
import android.os.SystemClock
|
||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||
import kotlinx.coroutines.CoroutineScope
|
||
import kotlinx.coroutines.Dispatchers
|
||
import kotlinx.coroutines.Job
|
||
import kotlinx.coroutines.cancel
|
||
import kotlinx.coroutines.SupervisorJob
|
||
import kotlinx.coroutines.delay
|
||
import kotlinx.coroutines.flow.MutableStateFlow
|
||
import kotlinx.coroutines.flow.asStateFlow
|
||
import kotlinx.coroutines.flow.StateFlow
|
||
import kotlinx.coroutines.launch
|
||
import javax.inject.Inject
|
||
import javax.inject.Singleton
|
||
|
||
@UnstableApi
|
||
@Singleton
|
||
class PlayerController @Inject constructor(
|
||
@ApplicationContext context: Context,
|
||
private val sleepSoundPlayer: SleepSoundPlayer,
|
||
private val audioEffects: AudioEffectsController
|
||
) {
|
||
// Анализатор спектра реального звука — для «живого» эквалайзера.
|
||
private val spectrumAnalyzer = AudioSpectrumAnalyzer()
|
||
val spectrum: StateFlow<FloatArray> = spectrumAnalyzer.spectrum
|
||
|
||
// RenderersFactory, который вставляет наш tee-процессор в аудио-конвейер
|
||
// (читает декодированный PCM, не меняя звук).
|
||
private val renderersFactory = object : DefaultRenderersFactory(context) {
|
||
override fun buildAudioSink(
|
||
context: Context,
|
||
enableFloatOutput: Boolean,
|
||
enableAudioTrackPlaybackParams: Boolean,
|
||
): AudioSink {
|
||
return DefaultAudioSink.Builder(context)
|
||
.setEnableFloatOutput(enableFloatOutput)
|
||
.setEnableAudioTrackPlaybackParams(enableAudioTrackPlaybackParams)
|
||
.setAudioProcessors(arrayOf(TeeAudioProcessor(spectrumAnalyzer)))
|
||
.build()
|
||
}
|
||
}
|
||
private val _isPlaying = MutableStateFlow(false)
|
||
val isPlaying: StateFlow<Boolean> = _isPlaying
|
||
|
||
private val _currentStationPrefix = MutableStateFlow<String?>(null)
|
||
val currentStationPrefix: StateFlow<String?> = _currentStationPrefix
|
||
|
||
// Id играющей станции — для подсветки активной карточки в списке.
|
||
private val _currentStationId = MutableStateFlow<Int?>(null)
|
||
val currentStationId: StateFlow<Int?> = _currentStationId
|
||
|
||
private val _icyTitle = MutableStateFlow<String?>(null)
|
||
val icyTitle: StateFlow<String?> = _icyTitle.asStateFlow()
|
||
|
||
// ── Таймер сна ──
|
||
// Оставшееся время в мс (null = таймер выключен). В последние FADE_MS звук
|
||
// плавно затухает (экспоненциальная кривая), затем пауза.
|
||
private val _sleepRemainingMs = MutableStateFlow<Long?>(null)
|
||
val sleepRemainingMs: StateFlow<Long?> = _sleepRemainingMs.asStateFlow()
|
||
private val timerScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||
private var sleepJob: Job? = null
|
||
|
||
// Переподключение при обрыве потока (дорога/туннели).
|
||
private var retryCount = 0
|
||
private var reconnectJob: Job? = null
|
||
|
||
private companion object {
|
||
const val FADE_MS = 20_000L // длительность плавного затухания в конце таймера
|
||
const val CROSSFADE_MS = 90_000L // переход радио → звук для сна (внутри аутро)
|
||
const val SOUND_VOL = 0.6f // комфортная громкость шума
|
||
const val SOUND_OUTRO_MS = 180_000L // финальное окно со звуком сна (последние ~3 мин)
|
||
}
|
||
|
||
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||
|
||
var onSkipToNext: (() -> Unit)? = null
|
||
var onSkipToPrevious: (() -> Unit)? = null
|
||
|
||
private val audioDeviceCallback = object : AudioDeviceCallback() {
|
||
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>?) {
|
||
val addedPlayback = addedDevices?.any { it.isPlaybackDevice() } == true
|
||
if (addedPlayback && _currentStationPrefix.value != null && !exoPlayer.isPlaying) {
|
||
Log.d("PlayerController", "Playback device connected → resume")
|
||
exoPlayer.play()
|
||
}
|
||
}
|
||
|
||
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>?) {
|
||
val removedPlayback = removedDevices?.any { it.isPlaybackDevice() } == true
|
||
if (removedPlayback && exoPlayer.isPlaying) {
|
||
Log.d("PlayerController", "Playback device removed → pause")
|
||
exoPlayer.pause()
|
||
}
|
||
}
|
||
}
|
||
|
||
private fun AudioDeviceInfo.isPlaybackDevice(): Boolean {
|
||
return isSink && (
|
||
type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP ||
|
||
type == AudioDeviceInfo.TYPE_WIRED_HEADPHONES ||
|
||
type == AudioDeviceInfo.TYPE_WIRED_HEADSET ||
|
||
type == AudioDeviceInfo.TYPE_USB_HEADSET ||
|
||
type == AudioDeviceInfo.TYPE_USB_DEVICE ||
|
||
type == AudioDeviceInfo.TYPE_BLE_HEADSET ||
|
||
type == AudioDeviceInfo.TYPE_BLE_SPEAKER
|
||
)
|
||
}
|
||
|
||
// HTTP-источник с разрешёнными кросс-протокольными редиректами (http→https):
|
||
// многие станции отдают 301 c http на https, без этого ExoPlayer их не играет.
|
||
private val mediaSourceFactory = DefaultMediaSourceFactory(
|
||
DefaultDataSource.Factory(
|
||
context,
|
||
DefaultHttpDataSource.Factory()
|
||
.setAllowCrossProtocolRedirects(true)
|
||
)
|
||
)
|
||
|
||
private val exoPlayer: ExoPlayer = ExoPlayer.Builder(context)
|
||
.setRenderersFactory(renderersFactory)
|
||
.setMediaSourceFactory(mediaSourceFactory)
|
||
.setAudioAttributes(
|
||
AudioAttributes.Builder()
|
||
.setUsage(C.USAGE_MEDIA)
|
||
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
||
.build(),
|
||
true
|
||
)
|
||
.setHandleAudioBecomingNoisy(true)
|
||
// Держим CPU + Wi-Fi активными, пока играем (partial wakelock + wifilock).
|
||
// Без этого при выключенном экране система усыпляет сеть → буфер пустеет →
|
||
// радио глохнет (главная причина «обрыва» в машине по Bluetooth).
|
||
.setWakeMode(C.WAKE_MODE_NETWORK)
|
||
.build()
|
||
.apply {
|
||
addListener(object : Player.Listener {
|
||
override fun onIsPlayingChanged(playing: Boolean) {
|
||
_isPlaying.value = playing
|
||
// Успешно играем — сбрасываем счётчик попыток переподключения.
|
||
if (playing) retryCount = 0
|
||
}
|
||
|
||
override fun onPlayerError(error: PlaybackException) {
|
||
// В дороге сигнал рвётся (туннели, край соты). Не глушим радио
|
||
// навсегда — пере-готовим поток с нарастающей задержкой.
|
||
Log.w("PlayerController", "Ошибка плеера: ${error.errorCodeName}, переподключение")
|
||
scheduleReconnect()
|
||
}
|
||
|
||
override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) {
|
||
val title = mediaMetadata.title?.toString()
|
||
if (!title.isNullOrBlank()) {
|
||
Log.d("PlayerController", "MediaMetadata title: $title")
|
||
_icyTitle.value = title
|
||
}
|
||
}
|
||
|
||
override fun onMetadata(metadata: Metadata) {
|
||
Log.d("PlayerController", "onMetadata called, length=${metadata.length()}")
|
||
for (i in 0 until metadata.length()) {
|
||
val entry = metadata.get(i)
|
||
Log.d("PlayerController", "Metadata entry[$i]: ${entry::class.java.simpleName}")
|
||
when (entry) {
|
||
is IcyInfo -> {
|
||
Log.d("PlayerController", "IcyInfo title='${entry.title}', url='${entry.url}', raw='${entry.rawMetadata}'")
|
||
entry.title?.let {
|
||
if (it.isNotBlank()) {
|
||
_icyTitle.value = it
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
})
|
||
// Фиксированная аудиосессия → эффекты (эквалайзер и т.д.) держатся на ней
|
||
// и переживают смену станций. Привязываем их сразу после создания плеера.
|
||
val sessionId = audioManager.generateAudioSessionId()
|
||
runCatching { setAudioSessionId(sessionId) }
|
||
audioEffects.attach(sessionId)
|
||
}
|
||
|
||
/**
|
||
* Переподключение после ошибки потока с нарастающей задержкой (2с→15с, до 10
|
||
* попыток ≈ пережить туннель). Счётчик сбрасывается, как только снова заиграло.
|
||
*/
|
||
private fun scheduleReconnect() {
|
||
reconnectJob?.cancel()
|
||
if (retryCount >= 10) return
|
||
val delayMs = (2_000L * (retryCount + 1)).coerceAtMost(15_000L)
|
||
retryCount++
|
||
reconnectJob = timerScope.launch {
|
||
delay(delayMs)
|
||
runCatching {
|
||
exoPlayer.prepare()
|
||
exoPlayer.playWhenReady = true
|
||
}
|
||
}
|
||
}
|
||
|
||
val player: Player = object : ForwardingPlayer(exoPlayer) {
|
||
override fun getAvailableCommands(): Player.Commands {
|
||
return super.getAvailableCommands()
|
||
.buildUpon()
|
||
.add(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
|
||
.add(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
|
||
.build()
|
||
}
|
||
|
||
override fun seekToNextMediaItem() {
|
||
onSkipToNext?.invoke() ?: super.seekToNextMediaItem()
|
||
}
|
||
|
||
override fun seekToPreviousMediaItem() {
|
||
onSkipToPrevious?.invoke() ?: super.seekToPreviousMediaItem()
|
||
}
|
||
}
|
||
|
||
init {
|
||
audioManager.registerAudioDeviceCallback(audioDeviceCallback, null)
|
||
}
|
||
|
||
fun play(url: String, stationPrefix: String, stationName: String, stationId: Int? = null) {
|
||
Log.d("PlayerController", "play() called with url=$url prefix=$stationPrefix")
|
||
// Новая станция — сбрасываем переподключение предыдущего потока.
|
||
reconnectJob?.cancel()
|
||
retryCount = 0
|
||
_currentStationId.value = stationId
|
||
_icyTitle.value = null
|
||
val mediaItem = MediaItem.Builder()
|
||
.setUri(url)
|
||
.setMediaMetadata(
|
||
MediaMetadata.Builder()
|
||
.setTitle(stationName)
|
||
.setArtist("")
|
||
.build()
|
||
)
|
||
.build()
|
||
exoPlayer.setMediaItem(mediaItem)
|
||
exoPlayer.prepare()
|
||
exoPlayer.play()
|
||
_currentStationPrefix.value = stationPrefix
|
||
}
|
||
|
||
/** Сменить URL потока (переключение качества) без потери текущих метаданных/обложки. */
|
||
fun changeStream(url: String) {
|
||
Log.d("PlayerController", "changeStream() url=$url")
|
||
val keepMetadata = exoPlayer.currentMediaItem?.mediaMetadata
|
||
_icyTitle.value = null
|
||
val builder = MediaItem.Builder().setUri(url)
|
||
if (keepMetadata != null) builder.setMediaMetadata(keepMetadata)
|
||
exoPlayer.setMediaItem(builder.build())
|
||
exoPlayer.prepare()
|
||
exoPlayer.play()
|
||
}
|
||
|
||
fun updateMetadata(song: String, artist: String, coverUrl: String, stationName: String) {
|
||
val currentMediaItem = exoPlayer.currentMediaItem ?: return
|
||
val artworkUri = coverUrl.takeIf { it.isNotBlank() }?.let { Uri.parse(it) }
|
||
val updatedMediaItem = currentMediaItem.buildUpon()
|
||
.setMediaMetadata(
|
||
MediaMetadata.Builder()
|
||
.setTitle(song)
|
||
.setArtist("$artist • $stationName")
|
||
.setAlbumTitle(stationName)
|
||
.setArtworkUri(artworkUri)
|
||
.build()
|
||
)
|
||
.build()
|
||
exoPlayer.replaceMediaItem(0, updatedMediaItem)
|
||
}
|
||
|
||
/**
|
||
* Запустить таймер сна на [durationMs] мс.
|
||
* Без [sound]: в последние FADE_MS радио экспоненциально затухает, затем пауза.
|
||
* Со [sound]: радио играет почти весь таймер; в последние SOUND_OUTRO_MS (не больше
|
||
* половины таймера) включается звук для сна — радио кроссфейдится в шум (радио ↓,
|
||
* шум ↑), шум держится, в самом конце затухает в тишину. Засыпаешь под радио, а не
|
||
* под резкий белый шум в первые же полторы минуты.
|
||
*/
|
||
fun startSleepTimer(durationMs: Long, sound: SleepSound? = null) {
|
||
sleepJob?.cancel()
|
||
exoPlayer.volume = 1f
|
||
sleepSoundPlayer.stop()
|
||
sleepJob = timerScope.launch {
|
||
val start = SystemClock.elapsedRealtime()
|
||
val end = start + durationMs
|
||
// Финальное окно со звуком — не длиннее половины таймера (для коротких).
|
||
val outro = if (sound != null) SOUND_OUTRO_MS.coerceAtMost(durationMs / 2) else 0L
|
||
// Кроссфейд радио→шум занимает первую половину аутро.
|
||
val crossfade = CROSSFADE_MS.coerceAtMost(outro / 2).coerceAtLeast(1L)
|
||
var soundStarted = false
|
||
while (true) {
|
||
val now = SystemClock.elapsedRealtime()
|
||
val remaining = end - now
|
||
if (remaining <= 0L) break
|
||
_sleepRemainingMs.value = remaining
|
||
when {
|
||
sound != null && remaining <= outro -> {
|
||
// Генератор шума стартуем лениво — только в аутро, не весь таймер.
|
||
if (!soundStarted) {
|
||
sleepSoundPlayer.start(sound)
|
||
soundStarted = true
|
||
}
|
||
val outroElapsed = outro - remaining
|
||
when {
|
||
outroElapsed < crossfade -> {
|
||
// Кроссфейд: радио вниз, шум вверх.
|
||
val f = outroElapsed.toFloat() / crossfade
|
||
exoPlayer.volume = (1f - f).coerceIn(0f, 1f)
|
||
sleepSoundPlayer.setVolume(f * SOUND_VOL)
|
||
}
|
||
remaining <= FADE_MS -> {
|
||
// Финальное затухание шума в тишину.
|
||
val frac = remaining.toFloat() / FADE_MS
|
||
sleepSoundPlayer.setVolume((frac * frac) * SOUND_VOL)
|
||
}
|
||
else -> {
|
||
// Радио отыграло — пауза, шум на комфортной громкости.
|
||
if (exoPlayer.isPlaying) exoPlayer.pause()
|
||
sleepSoundPlayer.setVolume(SOUND_VOL)
|
||
}
|
||
}
|
||
delay(150)
|
||
}
|
||
sound == null && remaining <= FADE_MS -> {
|
||
// Без звука: экспоненциальное затухание радио в конце.
|
||
val frac = remaining.toFloat() / FADE_MS
|
||
exoPlayer.volume = (frac * frac).coerceIn(0f, 1f)
|
||
delay(150)
|
||
}
|
||
else -> {
|
||
// Основная фаза: радио играет как обычно.
|
||
delay(1_000)
|
||
}
|
||
}
|
||
}
|
||
if (exoPlayer.isPlaying) exoPlayer.pause()
|
||
exoPlayer.volume = 1f
|
||
sleepSoundPlayer.stop()
|
||
_sleepRemainingMs.value = null
|
||
sleepJob = null
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Запуск воспроизведения станции по будильнику: играет [url] и плавно нарастает
|
||
* громкость 0 → 1 за [fadeInMs] (мягкое пробуждение).
|
||
*/
|
||
fun startAlarmPlayback(
|
||
url: String,
|
||
prefix: String,
|
||
name: String,
|
||
id: Int?,
|
||
fadeInMs: Long = 60_000L,
|
||
) {
|
||
cancelSleepTimer()
|
||
play(url, prefix, name, id)
|
||
exoPlayer.volume = 0f
|
||
sleepJob?.cancel()
|
||
sleepJob = timerScope.launch {
|
||
val start = SystemClock.elapsedRealtime()
|
||
while (true) {
|
||
val elapsed = SystemClock.elapsedRealtime() - start
|
||
if (elapsed >= fadeInMs) break
|
||
exoPlayer.volume = (elapsed.toFloat() / fadeInMs).coerceIn(0f, 1f)
|
||
delay(200)
|
||
}
|
||
exoPlayer.volume = 1f
|
||
sleepJob = null
|
||
}
|
||
}
|
||
|
||
/** Отменить таймер сна, вернуть громкость и заглушить звук сна. */
|
||
fun cancelSleepTimer() {
|
||
sleepJob?.cancel()
|
||
sleepJob = null
|
||
exoPlayer.volume = 1f
|
||
sleepSoundPlayer.stop()
|
||
_sleepRemainingMs.value = null
|
||
}
|
||
|
||
/** Включить/выключить расчёт спектра (FFT) — только пока открыт плеер. */
|
||
fun setSpectrumActive(active: Boolean) {
|
||
spectrumAnalyzer.active = active
|
||
}
|
||
|
||
fun pause() {
|
||
// Пауза пользователем — отменяем отложенное переподключение, иначе оно
|
||
// позже само возобновит воспроизведение.
|
||
reconnectJob?.cancel()
|
||
exoPlayer.pause()
|
||
}
|
||
|
||
fun play() {
|
||
exoPlayer.play()
|
||
}
|
||
|
||
fun stop() {
|
||
reconnectJob?.cancel()
|
||
exoPlayer.stop()
|
||
_currentStationPrefix.value = null
|
||
_currentStationId.value = null
|
||
}
|
||
|
||
fun release() {
|
||
timerScope.cancel()
|
||
audioManager.unregisterAudioDeviceCallback(audioDeviceCallback)
|
||
sleepSoundPlayer.stop()
|
||
exoPlayer.release()
|
||
}
|
||
}
|