fix(player): не глохнуть в фоне (wake mode) + авто-переподключение потока

Симптом: по Bluetooth в машине с выключенным экраном радио через время замолкало.
Причины и фиксы:
- setWakeMode(C.WAKE_MODE_NETWORK) + право WAKE_LOCK — ExoPlayer держит partial
  wakelock + wifilock во время игры. Без этого система усыпляла CPU/Wi-Fi при
  выключенном экране → буфер пустел → поток глох (главная причина).
- onPlayerError → scheduleReconnect(): при обрыве сети (туннели, край соты) поток
  пере-готавливается с нарастающей задержкой (2с→15с, до 10 попыток), а не
  замолкает навсегда. Счётчик сбрасывается при успешном старте; переподключение
  отменяется при ручной паузе/стопе/смене станции.
This commit is contained in:
nk
2026-06-07 12:35:07 +03:00
parent e736c2393f
commit 6eb614a729
2 changed files with 46 additions and 0 deletions

View File

@@ -13,6 +13,9 @@
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Авто-обновление: установка скачанного APK -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- Держать CPU/Wi-Fi активными во время проигрывания при выключенном экране
(иначе поток глохнет в фоне — особенно в машине по Bluetooth). -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:name=".RadiolaApplication"

View File

@@ -11,6 +11,7 @@ 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
@@ -85,6 +86,10 @@ class PlayerController @Inject constructor(
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 // переход радио → звук для сна (внутри аутро)
@@ -148,11 +153,24 @@ class PlayerController @Inject constructor(
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) {
@@ -188,6 +206,24 @@ class PlayerController @Inject constructor(
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()
@@ -212,6 +248,9 @@ class PlayerController @Inject constructor(
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()
@@ -373,6 +412,9 @@ class PlayerController @Inject constructor(
}
fun pause() {
// Пауза пользователем — отменяем отложенное переподключение, иначе оно
// позже само возобновит воспроизведение.
reconnectJob?.cancel()
exoPlayer.pause()
}
@@ -381,6 +423,7 @@ class PlayerController @Inject constructor(
}
fun stop() {
reconnectJob?.cancel()
exoPlayer.stop()
_currentStationPrefix.value = null
_currentStationId.value = null